shared/lib/auth/oidc.ts

98 lines
3.6 KiB
TypeScript
Raw 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/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<OidcUserinfo> {
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()
}