// GET /api/auth/callback — OIDC callback 处理 import { NextRequest, NextResponse } from 'next/server' import { exchangeCodeForToken, getUserinfo } from '@shared/lib/auth/oidc' import { signJwt } from '@shared/lib/auth/jwt' import { syncOidcUser } from '@shared/lib/auth/user-sync' import { authConfig } from '@/lib/auth-config' import { dbQueryParams, dbExec, escapeSql } from '@/lib/db' import { writeAuditLog } from '@shared/lib/audit/write-audit-log' export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url) const code = searchParams.get('code') const state = searchParams.get('state') const error = searchParams.get('error') // 使用 OIDC_REDIRECT_URI 构造公共 URL(LESSONS-LEARNED #16) const baseUrl = authConfig.oidcRedirectUri?.replace(/\/api\/auth\/callback.*/, '') || 'http://localhost:6181' if (error) { return NextResponse.redirect(new URL(`/login?error=${encodeURIComponent(error)}`, baseUrl)) } if (!code || !state) { return NextResponse.redirect(new URL('/login?error=missing_params', baseUrl)) } // 验证 state const savedState = request.cookies.get('oidc_state')?.value if (!savedState || savedState !== state) { return NextResponse.redirect(new URL('/login?error=state_mismatch', baseUrl)) } // 取出 code_verifier const codeVerifier = request.cookies.get('oidc_code_verifier')?.value if (!codeVerifier) { return NextResponse.redirect(new URL('/login?error=missing_verifier', baseUrl)) } // 换取 token const tokenResult = await exchangeCodeForToken( { autheliaUrl: authConfig.autheliaUrl, clientId: authConfig.oidcClientId, clientSecret: authConfig.oidcClientSecret, redirectUri: authConfig.oidcRedirectUri }, code, codeVerifier, ) if (!tokenResult.success) { return NextResponse.redirect(new URL(`/login?error=${encodeURIComponent(tokenResult.error || 'token_exchange_failed')}`, baseUrl)) } // 验证 nonce const savedNonce = request.cookies.get('oidc_nonce')?.value if (savedNonce && tokenResult.nonce && savedNonce !== tokenResult.nonce) { return NextResponse.redirect(new URL('/login?error=nonce_mismatch', baseUrl)) } // 获取 userinfo(带错误处理) let userinfo try { userinfo = await getUserinfo(authConfig.autheliaUrl, tokenResult.accessToken) } catch (e) { const msg = e instanceof Error ? e.message : 'userinfo_fetch_failed' return NextResponse.redirect(new URL(`/login?error=${encodeURIComponent(msg)}`, baseUrl)) } // 用户同步(使用参数化查询) const user = syncOidcUser({ getUser: (username) => { const rows = dbQueryParams<{ id: number; role: string }>('SELECT id, role FROM users WHERE username = ?', [username]) return rows[0] ?? null }, createUser: (username, displayName, email) => { dbExec('INSERT INTO users (username, display_name, email, role) VALUES (?, ?, ?, ?)', [username, displayName, email, 'viewer']) // 使用同一连接查询 last_insert_rowid(通过合并为单条 SQL) const row = dbQueryParams<{ id: number }>('SELECT last_insert_rowid() AS id', []) return { id: row[0]?.id ?? 0, role: 'viewer' } }, updateUser: (username, displayName, email) => { dbExec('UPDATE users SET display_name = ?, email = ?, updated_at = datetime(\'now\', \'+8 hours\') WHERE username = ?', [displayName, email, username]) }, }, userinfo) // 签发 JWT const token = signJwt({ secret: authConfig.jwtSecret, payload: { username: userinfo.preferred_username, displayName: userinfo.name, role: user.role }, }) const response = NextResponse.redirect(new URL('/dashboard', baseUrl)) // 签发 tlyq_session cookie response.cookies.set('tlyq_session', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', domain: process.env.NODE_ENV === 'production' ? authConfig.cookieDomain : undefined, path: '/', maxAge: 604800, }) // 清理 OIDC 临时 cookie response.cookies.delete('oidc_code_verifier') response.cookies.delete('oidc_state') response.cookies.delete('oidc_nonce') // 审计日志 writeAuditLog({ exec: dbExec }, { userId: user.id, username: userinfo.preferred_username, action: 'login', entityType: 'auth', details: { method: 'oidc', isNew: user.isNew }, ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1', }) return response }