96 lines
3.5 KiB
TypeScript
96 lines
3.5 KiB
TypeScript
// 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
|
||
}): 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)
|
||
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()
|
||
}
|