235 lines
8.2 KiB
TypeScript
235 lines
8.2 KiB
TypeScript
// 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 解析 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<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_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)
|
||
}
|
||
}
|