185 lines
5.0 KiB
TypeScript
185 lines
5.0 KiB
TypeScript
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<TokenPair> {
|
||
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<TokenPair> {
|
||
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<TokenPair> {
|
||
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<void> {
|
||
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,
|
||
};
|
||
}
|