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',
})
Magic Link Authentication¶
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,
}
},
})
Magic Link Security Features¶
| 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 |
Magic Link Flow¶
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¶
Magic Link Sign In¶
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:
- Layer 1 (Primary): Middleware - Fast, efficient, catches 99.9% of cases
- 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
}
Related Documentation¶
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¶
- ✅ Magic Link Authentication - Implemented with rate limiting
- ✅ OAuth Providers - Google and Facebook configured
- ✅ Session Management - JWT with 15-minute expiration
- ✅ Middleware Protection - Two-layer defense-in-depth
- ⏳ Password Reset Flow - Planned for future iteration