import { getOAuthClientId, getOAuthRedirectUri, getPublicApiOrigin } from '@/lib/env'; import { createPKCEPair } from '@/lib/oauth/pkce'; import type { ApiEnvelope } from '@/lib/api/types'; export type TokenPair = { accessToken: string; refreshToken: string; expiresIn: number; }; /** 用户名密码 + PKCE 换 authorization_code,再 POST /oauth/token。 */ export async function loginWithPassword(params: { userName: string; password: string; tenantId?: string; state?: string; }): Promise { const origin = getPublicApiOrigin(); const clientId = getOAuthClientId(); const redirectUri = getOAuthRedirectUri(); const { verifier, challenge } = await createPKCEPair(); const res = await fetch(`${origin}/api/v1/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ tenant_id: params.tenantId ?? '', user_name: params.userName, password: params.password, client_id: clientId, redirect_uri: redirectUri, code_challenge: challenge, code_challenge_method: 'S256', state: params.state ?? '', scope: 'openid', }), }); const json = (await res.json()) as ApiEnvelope<{ authorization_code?: string; state?: string; }>; if (res.status === 401) { throw new Error(json.msg || '用户名或密码错误'); } if (res.status === 403) { throw new Error(json.msg || '无权限'); } if (!res.ok) { throw new Error(json.msg || `HTTP ${res.status}`); } if (json.code !== 200 || !json.data?.authorization_code) { throw new Error(json.msg || '登录失败'); } return exchangeCodeForTokens({ code: json.data.authorization_code, codeVerifier: verifier, clientId, redirectUri, }); } export async function exchangeCodeForTokens(params: { code: string; codeVerifier: string; clientId: string; redirectUri: string; }): Promise { const origin = getPublicApiOrigin(); const body = new URLSearchParams({ grant_type: 'authorization_code', code: params.code, redirect_uri: params.redirectUri, client_id: params.clientId, code_verifier: params.codeVerifier, }); const res = await fetch(`${origin}/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), }); const data = (await res.json()) as { access_token?: string; refresh_token?: string; expires_in?: number; error?: string; error_description?: string; }; if (!res.ok || !data.access_token) { throw new Error(data.error_description || data.error || '换取 access_token 失败'); } return { accessToken: data.access_token, refreshToken: data.refresh_token ?? '', expiresIn: typeof data.expires_in === 'number' ? data.expires_in : 0, }; } export async function refreshAccessToken(refreshToken: string): Promise { const origin = getPublicApiOrigin(); const clientId = getOAuthClientId(); const body = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: clientId, }); const res = await fetch(`${origin}/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), }); const data = (await res.json()) as { access_token?: string; refresh_token?: string; expires_in?: number; error?: string; error_description?: string; }; if (!res.ok || !data.access_token) { throw new Error(data.error_description || data.error || '刷新 token 失败'); } return { accessToken: data.access_token, refreshToken: data.refresh_token ?? refreshToken, expiresIn: typeof data.expires_in === 'number' ? data.expires_in : 0, }; } export async function logoutRequest(): Promise { const origin = getPublicApiOrigin(); const res = await fetch(`${origin}/api/v1/auth/logout`, { method: 'POST', credentials: 'include', }); if (!res.ok) { const j = (await res.json().catch(() => ({}))) as { msg?: string }; throw new Error(j.msg || '登出失败'); } } /** RFC 7662 风格,用于解析 opaque access_token 的 `sub`(用户 id) */ export async function introspectAccessToken(accessToken: string): Promise<{ active: boolean; sub?: string; scope?: string; exp?: number; client_id?: string; }> { const origin = getPublicApiOrigin(); const body = new URLSearchParams({ token: accessToken, token_type_hint: 'access_token', }); const res = await fetch(`${origin}/oauth/introspect`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), }); const data = (await res.json()) as { active?: boolean; sub?: string; scope?: string; exp?: number; client_id?: string; }; return { active: Boolean(data.active), sub: data.sub, scope: data.scope, exp: data.exp, client_id: data.client_id, }; }