import NextAuth, { CredentialsSignin } from 'next-auth' import CredentialsProvider from 'next-auth/providers/credentials' import jwt from 'jsonwebtoken' import { createHmac } from 'node:crypto' import { getOidcStatus } from '@/lib/oidc-config' // Review SEC-2: browser IP attestation for Go's rate limiting (see lib/api.ts // for the identical semantics used by the data proxy). Duplicated here to keep // auth.ts import-free of lib/api.ts (avoiding a lib/api.ts → @/auth → lib/api.ts // cycle); both helpers must stay in sync. const observedClientIP = (headers: Headers): string | null => { if (process.env.TRUSTED_PROXY !== '1') return null const ip = headers.get('x-real-ip') return ip && ip.trim() ? ip.trim() : null } const signClientIP = (secret: string, ip: string): { ts: string; sig: string } => { const ts = String(Math.floor(Date.now() / 1000)) return { ts, sig: createHmac('sha256', secret).update(`${ip}|${ts}`).digest('hex') } } // NextAuth owns the browser session and the eventual client-managed OIDC flow. // Password verification and data ownership live in Go, not Prisma. export const { handlers, auth, signIn, signOut } = NextAuth({ trustHost: true, // Same precedence as Go (config.Secret) and lib/api.ts, so a deployment // with both variables set can never sign and verify with different values. secret: process.env.NEXTAUTH_SECRET || process.env.AUTH_SECRET, session: { strategy: 'jwt' }, pages: { signIn: '/login' }, providers: [ ...(getOidcStatus().enabled ? [{ id: 'authelia', name: 'Authelia', type: 'oidc', issuer: process.env.OIDC_ISSUER, clientId: process.env.OIDC_CLIENT_ID, clientSecret: process.env.OIDC_CLIENT_SECRET, authorization: { params: { scope: 'openid profile email' } }, profile(profile: any) { return { id: profile.sub, name: profile.name ?? profile.preferred_username ?? profile.sub, email: profile.email, image: profile.picture ?? null } }, } as any] : []), CredentialsProvider({ credentials: { email: { label: 'Email', type: 'email' }, password: { label: 'Password', type: 'password' } }, async authorize(credentials, request) { if (!credentials?.email || !credentials?.password) return null // Review SEC-1: browser logins arrive through this NextAuth path, not // the data proxy — without attested headers Go buckets every user on // this server's address (Compose) or exempted them entirely // (loopback, same-host setups), and one attacker could lock out any // account. NextAuth v5 passes the credentials Request as the second // argument; sign the observed browser IP the same way lib/api.ts does. const secret = process.env.NEXTAUTH_SECRET || process.env.AUTH_SECRET const headers: Record = { 'Content-Type': 'application/json' } const ip = observedClientIP(request?.headers ?? new Headers()) if (ip && secret) { const { ts, sig } = signClientIP(secret, ip) headers['x-computer-client-ip'] = ip headers['x-computer-client-ts'] = ts headers['x-computer-client-sig'] = sig } const response = await fetch(`${process.env.API_BASE_URL || 'http://localhost:8080'}/api/auth/login`, { method: 'POST', headers, cache: 'no-store', body: JSON.stringify({ email: credentials.email, password: credentials.password }), }) if (!response.ok) { // Review M-8: surface Go's 429 as a distinct CredentialsSignin code // so the login UI can say "too many attempts" instead of "invalid // credentials" or "check your connection". Auth.js carries `code` // through to the client's signIn() result. The status field remains // for server-side callers/tests. if (response.status === 429) { const err = new CredentialsSignin() as CredentialsSignin & { status?: number; retryAfter?: string } err.code = 'throttled' err.status = 429 const retry = response.headers.get('retry-after') if (retry) err.retryAfter = retry throw err } return null } const data = await response.json() // Carry Go's tokenVersion into the NextAuth JWT so every assertion // this session signs is revocable by a version bump ("sign out // everywhere") — review SEC-02. return { ...data.user, tokenVersion: data.tokenVersion } }, }), ], callbacks: { async signIn({ account }) { // Client integration point: exchange a verified Authelia identity for a // local Go User/Account before enabling OIDC. No subject is trusted as a // database user ID; the backend exchange stays an explicit 501 stub. return account?.provider !== 'authelia' }, async jwt({ token, user }) { if (user) { token.id = user.id; token.ver = user.tokenVersion } return token }, async session({ session, token }) { if (session.user) { session.user.id = token.id as string; session.user.ver = token.ver } return session }, }, events: { // Review AUTH-LIFECYCLE: a browser sign-out must reach Go's revocation. // Without this the tokenVersion never bumps on NextAuth sign-out: // previously minted assertions stay valid for the rest of their (≤120s) // lifetime and the user's Go cookie sessions survive the logout. Go's // /api/auth/signout means "sign out everywhere" — deliberately the same // semantics here. Fail-open: if Go is unreachable the local sign-out // still completes, degrading to the previous behavior. Review CR-02: // this event is the backstop — the UI's explicit /api/session/revoke // call is what makes revocation failure user-visible; here the outcome // is inspected, deadline-bound, and logged without secrets. async signOut(message) { const token = 'token' in message ? message.token : null const secret = process.env.NEXTAUTH_SECRET || process.env.AUTH_SECRET const id = typeof token?.id === 'string' ? token.id : '' const ver = typeof token?.ver === 'number' ? token.ver : 0 if (!secret || !id || ver < 1) return const controller = new AbortController() const timer = setTimeout(() => controller.abort(), 5000) try { const response = await fetch(`${process.env.API_BASE_URL || 'http://localhost:8080'}/api/auth/signout`, { method: 'POST', headers: { 'Content-Type': 'application/json', // Same short-lived assertion the data proxy signs (lib/api.ts). Authorization: `Bearer ${jwt.sign({ uid: id, ver, typ: 'assertion' }, secret, { algorithm: 'HS256', issuer: 'computer', expiresIn: '60s' })}`, }, cache: 'no-store', signal: controller.signal, }) if (response.status === 401) { // The assertion was rejected — overwhelmingly because an explicit // revoke already bumped the tokenVersion (the revocation this call // exists to guarantee has then already happened). Nothing to retry. console.warn(`signout event: revocation assertion rejected (401) for user ${id}; session is revoked or expired`) } else if (!response.ok) { // Local sign-out still proceeds, but global revocation did NOT // happen: other sessions/assertions survive. Make it visible. console.error(`signout event: Go revocation failed with ${response.status} for user ${id}; other sessions may remain active`) } } catch (error) { const kind = error instanceof Error ? error.name : 'Error' console.error(`signout event: Go revocation unreachable for user ${id} (${kind}); other sessions may remain active`) } finally { clearTimeout(timer) } }, }, })