// shared/lib/auth/handle-callback.ts — OIDC callback 工厂(步骤 1a 新增) // 统一 OIDC 回调流程:token 交换 → userinfo 获取 → 用户同步 → JWT 签发 → cookie 设置 → 审计 import { NextResponse, type NextRequest } from 'next/server' import { signJwtV2 } from './jwt-v2' import type { OidcCallbackUserInfo } from './types-v2' interface OidcConfig { autheliaUrl: string clientId: string clientSecret: string redirectUri: string } export interface HandleOidcCallbackConfig { oidc: OidcConfig jwtSecret: string cookieDomain: string /** 从本地 DB 查找已存在的用户 */ getUser: (username: string) => OidcCallbackUserInfo | null | Promise /** 创建新用户(首次登录),返回含 role 的用户信息 */ createUser?: (username: string, displayName: string, email: string) => OidcCallbackUserInfo | Promise /** 更新用户信息(displayName、email 变更时) */ updateUser?: (username: string, displayName: string, email: string) => void | Promise /** 审计日志回调 */ onAuditLog?: (userId: number, username: string, request: NextRequest) => void | Promise } /** 从 URL 解析 baseUrl(redirectUri 去除路径部分) */ function baseUrlFromRedirectUri(redirectUri: string): string { try { const url = new URL(redirectUri) return `${url.protocol}//${url.host}` } catch { return redirectUri.split('/api/')[0] } } /** 构建错误响应:redirect 到 /login?error= 并清除 OIDC 临时 cookie(try/finally 确保) */ function loginError(baseUrl: string, request: NextRequest): NextResponse { const response = NextResponse.redirect(new URL(`/login?error=login_failed`, baseUrl)) for (const name of ['oidc_code_verifier', 'oidc_state', 'oidc_nonce']) { if (request.cookies.get(name)) response.cookies.delete(name) } return response } /** OIDC 回调处理。成功返回 200 JSON + Set-Cookie,失败返回 redirect 到 /login?error= */ export async function handleOidcCallback( request: NextRequest, config: HandleOidcCallbackConfig ): Promise { const baseUrl = baseUrlFromRedirectUri(config.oidc.redirectUri) try { const url = new URL(request.url) const code = url.searchParams.get('code') const state = url.searchParams.get('state') const error = url.searchParams.get('error') if (error) { console.error(`OIDC error: ${error}`) return loginError(baseUrl, request) } if (!code || !state) { return loginError(baseUrl, request) } // 1. 验证 state const savedState = request.cookies.get('oidc_state')?.value if (state !== savedState) { console.error('OIDC state mismatch') return loginError(baseUrl, request) } // 2. 获取 code_verifier const codeVerifier = request.cookies.get('oidc_code_verifier')?.value if (!codeVerifier) { console.error('OIDC code_verifier missing') return loginError(baseUrl, request) } // 3. 交换 token // client_secret_post:secret 放入 POST body(Authelia 不支持 client_secret_basic) const tokenRes = await fetch(`${config.oidc.autheliaUrl}/api/oidc/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: config.oidc.redirectUri, client_id: config.oidc.clientId, client_secret: config.oidc.clientSecret, code_verifier: codeVerifier, }), }) if (!tokenRes.ok) { console.error(`Token exchange failed: ${tokenRes.status}`) return loginError(baseUrl, request) } const tokenData = await tokenRes.json() const accessToken = tokenData.access_token as string const idToken = tokenData.id_token as string | undefined // 4. 验证 id_token nonce(严格三阶段:生成→id_token 解码→比对,任一缺失/不匹配→拒绝) if (idToken) { const idParts = idToken.split('.') if (idParts.length !== 3) { console.error('OIDC id_token malformed') return loginError(baseUrl, request) } try { const idPayload = JSON.parse(Buffer.from(idParts[1], 'base64url').toString()) const savedNonce = request.cookies.get('oidc_nonce')?.value if (!savedNonce) { console.error('OIDC nonce missing in cookie') return loginError(baseUrl, request) } if (!idPayload.nonce) { console.error('OIDC nonce missing in id_token') return loginError(baseUrl, request) } if (idPayload.nonce !== savedNonce) { console.error('OIDC nonce mismatch') return loginError(baseUrl, request) } // 验证 aud(设计方案 §3.3,裸 fetch 替代 openid-client 需手动校验) if (!idPayload.aud) { console.error('OIDC aud missing in id_token') return loginError(baseUrl, request) } const audList = Array.isArray(idPayload.aud) ? idPayload.aud : [idPayload.aud] if (!audList.includes(config.oidc.clientId)) { console.error('OIDC aud mismatch') return loginError(baseUrl, request) } } catch { console.error('OIDC id_token parse failed') return loginError(baseUrl, request) } } // 5. 获取 userinfo const userinfoRes = await fetch(`${config.oidc.autheliaUrl}/api/oidc/userinfo`, { headers: { Authorization: `Bearer ${accessToken}` }, }) if (!userinfoRes.ok) { console.error(`Userinfo fetch failed: ${userinfoRes.status}`) return loginError(baseUrl, request) } const userinfo = await userinfoRes.json() const username = userinfo.preferred_username as string const displayName = (userinfo.name as string) || username const email = (userinfo.email as string) || '' // 6. sub 一致性检查(id_token sub 必须与 userinfo sub 一致) if (idToken && userinfo.sub) { try { const idParts = idToken.split('.') const idPayload = JSON.parse(Buffer.from(idParts[1], 'base64url').toString()) if (idPayload.sub && idPayload.sub !== userinfo.sub) { console.error('OIDC sub mismatch') return loginError(baseUrl, request) } } catch { /* sub check failure is non-fatal if id_token is unavailable */ } } // 7. 用户同步(查找或创建) let user: OidcCallbackUserInfo | null = await Promise.resolve(config.getUser(username)) const isNew = !user if (!user && config.createUser) { user = await Promise.resolve(config.createUser(username, displayName, email)) } if (!user) { console.error(`User ${username} not found and createUser not provided`) return loginError(baseUrl, request) } // 8. 更新用户信息(如果 displayName 或 email 变更) if (!isNew && config.updateUser) { try { await Promise.resolve(config.updateUser(username, displayName, email)) } catch (e) { console.error('updateUser failed:', e) // 不阻塞登录 } } // 9. 签发 JWT(不含 role) const token = signJwtV2({ secret: config.jwtSecret, payload: { username, displayName }, iss: config.oidc.autheliaUrl, }) // 10. 签发 cookie + 302 跳转首页(OIDC 回调是浏览器导航,必须 redirect 而非 JSON) const response = NextResponse.redirect(new URL('/', baseUrl)) response.cookies.set('tlyq_session', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' as const, domain: config.cookieDomain, path: '/', maxAge: 604800, }) // 11. 清理 OIDC 临时 cookie for (const name of ['oidc_code_verifier', 'oidc_state', 'oidc_nonce']) { response.cookies.delete(name) } // 12. 审计日志 if (config.onAuditLog) { try { await Promise.resolve(config.onAuditLog(user.id, username, request)) } catch (e) { console.error('onAuditLog failed:', e) // 不阻塞登录 } } return response } catch (e) { console.error('handleOidcCallback error:', e) return loginError(baseUrl, request) } }