shared/lib/auth/handle-callback.ts

235 lines
8.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<OidcCallbackUserInfo | null>
/** 创建新用户(首次登录),返回含 role 的用户信息 */
createUser?: (username: string, displayName: string, email: string) => OidcCallbackUserInfo | Promise<OidcCallbackUserInfo>
/** 更新用户信息displayName、email 变更时) */
updateUser?: (username: string, displayName: string, email: string) => void | Promise<void>
/** 审计日志回调 */
onAuditLog?: (userId: number, username: string, request: NextRequest) => void | Promise<void>
}
/** 从 URL 解析 baseUrlredirectUri 去除路径部分) */
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 临时 cookietry/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<NextResponse> {
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_postsecret 放入 POST bodyAuthelia 不支持 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)
}
}