// shared/lib/auth/oidc.ts — OIDC 客户端(Authelia discovery + PKCE flow) import crypto from 'crypto' import type { OidcUserinfo } from './types' export interface OidcClientConfig { autheliaUrl: string clientId: string clientSecret: string redirectUri: string } // OpenID Connect Discovery export async function discoverOidcConfig(autheliaUrl: string) { const res = await fetch(`${autheliaUrl}/.well-known/openid-configuration`, { headers: { Accept: 'application/json' }, }) if (!res.ok) throw new Error(`OIDC discovery failed: ${res.status}`) return res.json() as Promise<{ authorization_endpoint: string; token_endpoint: string; userinfo_endpoint: string; end_session_endpoint?: string }> } // 生成 PKCE 参数 export function generatePkce(): { codeVerifier: string; codeChallenge: string } { const codeVerifier = crypto.randomBytes(32).toString('base64url') const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url') return { codeVerifier, codeChallenge } } // 生成 state 和 nonce export function generateState(): string { return crypto.randomBytes(32).toString('base64url') } // 构建 OIDC authorize URL export function buildAuthorizeUrl(config: OidcClientConfig, params: { codeChallenge: string state: string nonce: string prompt?: string maxAge?: number }): string { const { autheliaUrl, clientId, redirectUri } = config const url = new URL(`${autheliaUrl}/api/oidc/authorization`) url.searchParams.set('client_id', clientId) url.searchParams.set('redirect_uri', redirectUri) url.searchParams.set('response_type', 'code') url.searchParams.set('scope', 'openid profile email') url.searchParams.set('state', params.state) url.searchParams.set('nonce', params.nonce) url.searchParams.set('code_challenge', params.codeChallenge) url.searchParams.set('code_challenge_method', 'S256') if (params.prompt) url.searchParams.set('prompt', params.prompt) if (params.maxAge !== undefined) url.searchParams.set('max_age', String(params.maxAge)) return url.toString() } // code 换取 token export async function exchangeCodeForToken(config: OidcClientConfig, code: string, codeVerifier: string) { const { autheliaUrl, clientId, clientSecret, redirectUri } = config const oidcConfig = await discoverOidcConfig(autheliaUrl) const res = await fetch(oidcConfig.token_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: clientId, client_secret: clientSecret, code_verifier: codeVerifier, }), }) if (!res.ok) return { success: false as const, error: `Token exchange failed: ${res.status}` } const data = await res.json() // nonce 在 ID token 的 claims 中,不在 token endpoint 响应中 let nonce: string | undefined if (data.id_token) { try { const payload = JSON.parse(Buffer.from(data.id_token.split('.')[1], 'base64url').toString()) nonce = payload.nonce } catch { /* 解析失败则 nonce 为 undefined */ } } return { success: true as const, accessToken: data.access_token, idToken: data.id_token, nonce } } // 获取 userinfo export async function getUserinfo(autheliaUrl: string, accessToken: string): Promise { const oidcConfig = await discoverOidcConfig(autheliaUrl) const res = await fetch(oidcConfig.userinfo_endpoint, { headers: { Authorization: `Bearer ${accessToken}` }, }) if (!res.ok) throw new Error(`Userinfo fetch failed: ${res.status}`) return res.json() }