Skip to content

Authentication

Last Updated: 2025-01-22

Authentication in AccessALI is powered by NextAuth.js v5, providing secure magic link and OAuth authentication with session management and rate limiting.


Overview

NextAuth.js v5 provides:

  • Magic Links - Passwordless email authentication with rate limiting
  • OAuth Providers - Google, Facebook integration with email domain validation
  • Session Management - JWT-based sessions with 15-minute expiration
  • Type Safety - Full TypeScript support with extended session types
  • Security - HTTP-only cookies, CSRF protection, rate limiting

Configuration

Environment Variables

# NextAuth.js
NEXTAUTH_SECRET="your-secret-key-minimum-32-characters-long"
NEXTAUTH_URL="http://localhost:3000"

# OAuth Providers (Optional)
GOOGLE_CLIENT_ID="your-google-client-id"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
FACEBOOK_CLIENT_ID="your-facebook-client-id"
FACEBOOK_CLIENT_SECRET="your-facebook-client-secret"

# Email Domain Validation
ALLOWED_EMAIL_DOMAIN="stratpoint.com"

# Email Service (Resend)
RESEND_API_KEY="re_xxxxx"
EMAIL_FROM="noreply@accessali.com"
EMAIL_FROM_NAME="AccessALI"

# Session Configuration
AUTH_SESSION_MAX_AGE_SECONDS="900" # 15 minutes

Auth Configuration

Source: src/lib/auth.ts:78-469

import NextAuth from 'next-auth'
import Google from 'next-auth/providers/google'
import Facebook from 'next-auth/providers/facebook'
import Credentials from 'next-auth/providers/credentials'
import { PrismaAdapter } from '@auth/prisma-adapter'
import { prisma } from '@/lib/prisma'

export const { handlers, auth, signIn, signOut } = NextAuth({
  /**
   * Prisma Adapter
   * Connects NextAuth.js to the database via Prisma
   * Handles user creation, session management, and OAuth accounts
   */
  adapter: PrismaAdapter(prisma) as Adapter,

  /**
   * Trust Host
   * Allow requests from the production domain via Cloudflare tunnel
   * Required for NextAuth.js v5 when behind a proxy
   */
  trustHost: true,

  /**
   * Session Strategy
   * JWT: Stateless sessions stored in HTTP-only cookies
   * maxAge: 15 minutes (900 seconds) for enhanced security
   */
  session: {
    strategy: 'jwt',
    maxAge: 900, // 15 minutes
  },

  /**
   * Custom Pages
   * Override default NextAuth.js pages with custom designs
   */
  pages: {
    signIn: '/login',
    error: '/login',
    verifyRequest: '/auth/verify',
  },

  /**
   * Authentication Providers
   */
  providers: [
    Google({
      clientId: env.GOOGLE_CLIENT_ID,
      clientSecret: env.GOOGLE_CLIENT_SECRET,
    }),
    Facebook({
      clientId: env.FACEBOOK_CLIENT_ID,
      clientSecret: env.FACEBOOK_CLIENT_SECRET,
    }),
    // Magic Link provider (see Magic Link section below)
  ],

  callbacks: {
    // See Callbacks section below for full implementation
  },

  /**
   * Security Configuration
   */
  cookies: {
    sessionToken: {
      name:
        process.env.NODE_ENV === 'production'
          ? '__Secure-next-auth.session-token'
          : 'next-auth.session-token',
      options: {
        httpOnly: true,        // Prevents XSS attacks
        sameSite: 'lax',       // CSRF protection
        secure: process.env.NODE_ENV === 'production',
      },
    },
  },

  debug: process.env.NODE_ENV === 'development',
})

Provider Configuration

Source: src/lib/auth.ts:149-214

Credentials({
  id: 'magic-link',
  name: 'Magic Link',
  credentials: {
    token: {
      label: 'Token',
      type: 'text',
      placeholder: '64-character hex token'
    },
  },
  async authorize(credentials) {
    // 1. Validate token format (64 hex characters)
    if (!/^[a-f0-9]{64}$/i.test(credentials.token)) {
      return null
    }

    // 2. Rate limiting check (using first 16 chars as identifier)
    // Prevents brute force attacks without exposing full token
    const rateLimitId = credentials.token.substring(0, 16)
    const rateLimit = await checkRateLimit(
      rateLimitId,
      RATE_LIMITS.MAGIC_LINK_VERIFY
    )

    if (!rateLimit.success) {
      throw new RateLimitExceededError(
        new Date(rateLimit.reset!),
        'Too many verification attempts'
      )
    }

    // 3. Validate magic link token
    const result = await validateMagicLink(credentials.token)

    if (!result.success || !result.user) {
      return null
    }

    // 4. Return user object - NextAuth creates session
    return {
      id: user.id,
      email: user.email,
      name: `${user.firstName} ${user.lastName}`.trim(),
      role: user.role,
      firstName: user.firstName,
    }
  },
})
Feature Configuration Purpose
Token Format 64-character hex string Cryptographically secure
Token Hashing SHA-256 in database Prevents token theft from DB breach
Single-Use Deleted after validation Prevents replay attacks
Expiration 15 minutes Limits attack window
Rate Limiting 5 attempts per 15 minutes Prevents brute force
sequenceDiagram
    participant User
    participant Client
    participant Server
    participant Email
    participant Database

    User->>Client: Request magic link
    Client->>Server: sendMagicLink(email)
    Server->>Server: Rate limit check
    Server->>Server: Generate token (64 hex chars)
    Server->>Server: Hash token (SHA-256)
    Server->>Database: Store hashed token
    Server->>Email: Send email with link
    Email->>User: Deliver email
    User->>Client: Click magic link
    Client->>Server: verifyMagicLink(token)
    Server->>Server: Rate limit check
    Server->>Server: Hash provided token
    Server->>Database: Find & validate token
    Database->>Server: Return user data
    Server->>Database: Delete used token
    Server->>Client: Create session (HTTP-only cookie)
    Client->>User: Redirect to dashboard

Callbacks

JWT Callback

Source: src/lib/auth.ts:242-269

Runs whenever a JWT is created or updated. Adds custom properties to the token.

async jwt({ token, user, trigger, profile }): Promise<JWT> {
  // On sign in, add user ID, role, and firstName to token
  if (user) {
    if (user.id && typeof user.id === 'string') {
      token.id = user.id
    }

    // Safely assign role with type guard
    if (user.role && isUserRole(user.role)) {
      token.role = user.role
    } else {
      token.role = 'BUYER' // Safe default
    }

    token.firstName = user.firstName ?? null
  }

  // Handle OAuth profile updates
  if (trigger === 'signIn' && profile) {
    token.name = profile.given_name ?? profile.name
    token.email = profile.email
    token.picture = profile.picture
  }

  return token
}

Session Callback

Source: src/lib/auth.ts:280-314

Runs whenever a session is checked. Adds custom properties to the session.

async session({ session, token }): Promise<typeof session> {
  // Add user ID, role, and firstName to session
  if (token && session.user) {
    if (token.id && typeof token.id === 'string') {
      session.user.id = token.id
    }

    if (isUserRole(token.role)) {
      session.user.role = token.role
    } else {
      session.user.role = 'BUYER' // Safe default
    }

    // Use firstName from token (avoid DB query for performance)
    session.user.firstName = token.firstName ?? null
  }

  return session
}

SignIn Callback

Source: src/lib/auth.ts:325-418

Controls whether a user is allowed to sign in. Implements email domain validation and user creation.

async signIn({ user, account, profile }): Promise<boolean> {
  // Magic link sign-ins (already validated in authorize)
  if (account?.provider === 'magic-link') {
    // Update last login
    await prisma.user.update({
      where: { id: user.id },
      data: { lastLogin: new Date() },
    })
    return true
  }

  // OAuth sign-ins (Google, Facebook)
  if (account?.provider === 'google' || account?.provider === 'facebook') {
    const candidateEmail = user.email
    const allowedDomain = env.ALLOWED_EMAIL_DOMAIN
    const domain = candidateEmail.split('@').pop()?.toLowerCase()

    // Email domain validation
    if (domain !== allowedDomain) {
      return false // Deny login, redirect with error=AccessDenied
    }

    const existingUser = await prisma.user.findUnique({
      where: { email: candidateEmail },
    })

    // Create new user with PENDING status (needs admin activation)
    if (!existingUser) {
      await prisma.user.create({
        data: {
          email: user.email,
          status: 'PENDING',
          role: 'BUYER',
          emailVerified: new Date(),
          firstName: profile?.given_name,
          lastName: profile?.family_name,
          profilePhoto: user.image,
        },
      })
      return false // Deny until admin activates
    }

    // Deny if user is still PENDING
    if (existingUser?.status === 'PENDING') {
      return false
    }

    // Update last login
    await prisma.user.update({
      where: { id: existingUser.id },
      data: { lastLogin: new Date() },
    })

    return true
  }

  return true
}

Type Extensions

Source: src/lib/auth.ts:41-66

Extend NextAuth types to include custom user properties for type safety.

declare module 'next-auth' {
  interface Session {
    user: {
      id: string
      email: string
      name: string | null
      image: string | null
      role: UserRole
      firstName: string | null
    } & DefaultSession['user']
  }

  interface User {
    role: UserRole
    firstName: string | null
    lastName: string | null
  }
}

declare module 'next-auth/jwt' {
  interface JWT {
    id: string
    role: UserRole
    firstName: string | null
  }
}

Usage Patterns

In Server Components

Real Example: Dashboard page authentication

import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'

export default async function DashboardPage() {
  // Check authentication using NextAuth.js
  const session = await auth()

  // Redirect to login if not authenticated
  if (!session?.user) {
    redirect('/login')
  }

  // Access type-safe user data
  const { id, email, firstName, role } = session.user

  return (
    <div>
      Welcome, {firstName ?? email}
      {role === 'ADMIN' && <AdminBadge />}
    </div>
  )
}

In Server Actions

Real Example: Dashboard data action

Source: src/app/actions/dashboard.ts:12-40

'use server'

import { auth } from '@/lib/auth'

export async function getDashboardData(): Promise<DashboardData> {
  // 1. Check authentication
  const session = await auth()

  if (!session?.user?.id) {
    throw new Error('Unauthorized: You must be logged in')
  }

  // 2. Extract user ID from session
  const userId = session.user.id

  // 3. Fetch data with authenticated user ID
  const dashboardData = await getDashboardDataUseCase(userId)

  return dashboardData
}

In API Routes

import { auth } from '@/lib/auth'
import { NextResponse } from 'next/server'

export async function GET(request: Request) {
  const session = await auth()

  if (!session?.user?.id) {
    return NextResponse.json(
      { error: 'Unauthorized' },
      { status: 401 }
    )
  }

  const data = await getData(session.user.id)
  return NextResponse.json({ data })
}

In Client Components

'use client'

import { useSession } from 'next-auth/react'

export function UserProfile() {
  const { data: session, status } = useSession()

  if (status === 'loading') {
    return <div>Loading...</div>
  }

  if (!session) {
    return <div>Not authenticated</div>
  }

  return (
    <div>
      Hello, {session.user.firstName ?? session.user.email}
      <p>Role: {session.user.role}</p>
    </div>
  )
}

Login/Logout

Real Flow: Send magic link via email, user clicks link to authenticate

'use client'

import { sendMagicLink } from '@/app/actions/auth'
import { useState } from 'react'

export function MagicLinkForm() {
  const [email, setEmail] = useState('')
  const [isLoading, setIsLoading] = useState(false)

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault()
    setIsLoading(true)

    const result = await sendMagicLink(email)

    if (result.success) {
      // Show success message - check email
      alert('Magic link sent! Check your email.')
    } else {
      // Show error message (e.g., rate limit)
      alert(result.message || 'Failed to send magic link')
    }

    setIsLoading(false)
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Enter your email"
        required
      />
      <button type="submit" disabled={isLoading}>
        {isLoading ? 'Sending...' : 'Send Magic Link'}
      </button>
    </form>
  )
}

OAuth Sign In

'use client'

import { signIn } from 'next-auth/react'

export function OAuthButtons() {
  return (
    <div>
      <button onClick={() => signIn('google', { callbackUrl: '/' })}>
        Sign in with Google
      </button>

      <button onClick={() => signIn('facebook', { callbackUrl: '/' })}>
        Sign in with Facebook
      </button>
    </div>
  )
}

OAuth Email Domain Validation

AccessALI validates OAuth email domains via ALLOWED_EMAIL_DOMAIN environment variable. Users with non-matching domains are denied with error=AccessDenied.

Sign Out

'use client'

import { signOut } from 'next-auth/react'

export function LogoutButton() {
  return (
    <button onClick={() => signOut({ callbackUrl: '/login' })}>
      Sign Out
    </button>
  )
}

Middleware Protection

Source: src/middleware.ts:209-350

Middleware is the primary authentication layer that runs on every request before it reaches any page component.

Route Configuration

/**
 * Public routes - accessible to all users
 */
const PUBLIC_ROUTES = [
  '/login',
  '/activate',
  '/terms',
  '/privacy',
]

/**
 * Protected routes - require authentication
 */
const PROTECTED_ROUTES = [
  '/dashboard',
  '/properties',
  '/payments',
  '/documents',
  '/appointments',
  '/messages',
  '/profile',
  '/',
]

/**
 * Admin routes - require ADMIN role
 */
const ADMIN_ROUTES = [
  '/admin',
]

Middleware Implementation

export default auth(async (req) => {
  const { pathname } = req.nextUrl
  const isAuthenticated = !!req.auth
  const userRole = req.auth?.user?.role

  // 1. PUBLIC ROUTES - Allow access to everyone
  if (isPublicRoute(pathname)) {
    // Redirect authenticated users away from login page
    if (pathname === '/login' && isAuthenticated) {
      return NextResponse.redirect(new URL('/', req.url))
    }
    return NextResponse.next()
  }

  // 2. PROTECTED ROUTES - Require authentication
  if (isProtectedRoute(pathname)) {
    if (!isAuthenticated) {
      // Redirect to login with callback URL preservation
      const loginUrl = new URL('/login', req.url)
      loginUrl.searchParams.set('callbackUrl', pathname)
      return NextResponse.redirect(loginUrl)
    }
    return NextResponse.next()
  }

  // 3. ADMIN ROUTES - Require ADMIN role
  if (isAdminRoute(pathname)) {
    if (!isAuthenticated) {
      const loginUrl = new URL('/login', req.url)
      loginUrl.searchParams.set('callbackUrl', pathname)
      return NextResponse.redirect(loginUrl)
    }

    // Check admin role
    if (userRole !== 'ADMIN') {
      const dashboardUrl = new URL('/dashboard', req.url)
      dashboardUrl.searchParams.set('error', 'Unauthorized: Admin access required')
      return NextResponse.redirect(dashboardUrl)
    }

    return NextResponse.next()
  }

  return NextResponse.next()
})

export const config = {
  matcher: [
    '/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
  ],
}

Defense-in-Depth Strategy

AccessALI implements two-layer authentication:

  1. Layer 1 (Primary): Middleware - Fast, efficient, catches 99.9% of cases
  2. Layer 2 (Secondary): Page components - Fail-safe for edge cases

Why two layers? - Middleware may have timing issues or configuration bugs - Page components provide a safety net - Industry best practice: Never rely on a single security layer

Rate Limiting

Middleware includes rate limiting for:

Endpoint Limit Window Purpose
Login Page 10 requests 15 minutes Prevent brute force
Magic Link Verify 5 attempts 15 minutes Prevent token guessing
API Routes 100 requests 15 minutes General API protection

Source: src/middleware.ts:155-269

// Rate limit login/signup pages
if (pathname === '/login') {
  const clientIp = getClientIp(req)
  const rateLimitResponse = await checkRateLimitMiddleware(
    req,
    clientIp,
    loginRateLimit,
    'login-page'
  )
  if (rateLimitResponse) return rateLimitResponse
}

// Rate limit magic link verification
if (pathname.includes('magic-link')) {
  const identifier = sanitizedEmail || clientIp
  const rateLimitResponse = await checkRateLimitMiddleware(
    req,
    identifier,
    magicLinkRateLimit,
    'magic-link'
  )
  if (rateLimitResponse) return rateLimitResponse
}


Security Best Practices

HTTP-Only Cookies

Session tokens are stored as HTTP-only cookies, preventing XSS attacks:

cookies: {
  sessionToken: {
    options: {
      httpOnly: true,        // Prevents JavaScript access
      sameSite: 'lax',       // CSRF protection
      secure: true,          // HTTPS only in production
    },
  },
}

Token Security

  • Magic Link Tokens: 64-character hex strings, SHA-256 hashed in database
  • Single-Use: Tokens deleted immediately after validation
  • Expiration: 15-minute window limits attack surface
  • Rate Limiting: Prevents brute force and spam attacks

Email Domain Validation

// Validate email domain in signIn callback
const allowedDomain = env.ALLOWED_EMAIL_DOMAIN
const domain = email.split('@').pop()?.toLowerCase()

if (domain !== allowedDomain) {
  return false // Deny access
}

Next Steps

  1. Magic Link Authentication - Implemented with rate limiting
  2. OAuth Providers - Google and Facebook configured
  3. Session Management - JWT with 15-minute expiration
  4. Middleware Protection - Two-layer defense-in-depth
  5. Password Reset Flow - Planned for future iteration