feat: 优化web
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user