feat: enhance host API authentication handling and add regression tests

This commit is contained in:
duanshuwen
2026-04-23 19:09:30 +08:00
parent 71bcc3b3c5
commit c9617a3777
10 changed files with 471 additions and 127 deletions

View File

@@ -1,11 +1,12 @@
import { IPC_EVENTS } from './constants';
import type { HostApiResult } from '../types/runtime';
import { logout, readPersistedAuthToken } from '../router/auth-session';
import { readPersistedAuthToken } from '../router/auth-session';
type RequestInitLike = Pick<RequestInit, 'method' | 'headers' | 'body'>;
const HOST_API_PORT = 13210;
const HOST_API_BASE = `http://127.0.0.1:${HOST_API_PORT}`;
const HOST_API_UNAUTHORIZED_CODE = 'HOST_API_UNAUTHORIZED';
type LooseIpcBridge = {
invoke<T = unknown>(channel: string, ...args: any[]): Promise<T>;
@@ -14,6 +15,11 @@ type LooseIpcBridge = {
let cachedHostApiToken: string | null = null;
type HostApiErrorDetails = {
code?: string;
message: string;
};
function normalizeHeaders(headers?: HeadersInit): Headers {
return new Headers(headers ?? {});
}
@@ -41,9 +47,6 @@ function extractResult<T>(response: unknown): T {
if (response && typeof response === 'object') {
const result = response as HostApiResult<T>;
if (result.success === false || result.ok === false) {
if (isUnauthorizedStatus(result.status) || isUnauthorizedMessage(result.error) || isUnauthorizedMessage(result.text)) {
handleUnauthorized();
}
throw new Error(result.error || result.text || 'Request failed');
}
@@ -57,23 +60,12 @@ function extractResult<T>(response: unknown): T {
return response as T;
}
function isUnauthorizedStatus(status?: number): boolean {
return status === 401;
function isHostApiUnauthorized(code?: string): boolean {
return code === HOST_API_UNAUTHORIZED_CODE;
}
function isUnauthorizedMessage(message?: string): boolean {
if (!message) return false;
return /\b401\b|unauthorized|unauthenticated|invalid token|token expired|鉴权失败|认证失败|未授权|未登录|登录失效|token已失效/i.test(message);
}
function handleUnauthorized(): void {
const from = typeof window === 'undefined' ? undefined : window.location.hash.replace(/^#/, '') || undefined;
logout({ reason: 'unauthorized', from });
}
async function getHostApiToken(): Promise<string> {
if (cachedHostApiToken) {
async function getHostApiToken(forceRefresh = false): Promise<string> {
if (!forceRefresh && cachedHostApiToken) {
return cachedHostApiToken;
}
@@ -81,11 +73,34 @@ async function getHostApiToken(): Promise<string> {
return cachedHostApiToken;
}
async function parseHostApiError(response: Response): Promise<HostApiErrorDetails> {
const contentType = response.headers.get('content-type') ?? '';
if (contentType.includes('application/json')) {
const payload = (await response.json().catch(() => ({}))) as HostApiResult<unknown> & { message?: string };
return {
code: typeof payload.code === 'string' ? payload.code : undefined,
message:
payload.error
|| payload.text
|| payload.message
|| response.statusText
|| `Request failed with ${response.status}`,
};
}
const text = await response.text();
return {
message: text || response.statusText || `Request failed with ${response.status}`,
};
}
async function fetchViaLocalHostApi<T>(
path: string,
method: string,
headers: Headers,
body: BodyInit | null | undefined,
allowRetry = true,
): Promise<T> {
const hostApiToken = await getHostApiToken();
const localHeaders = new Headers(headers);
@@ -103,14 +118,14 @@ async function fetchViaLocalHostApi<T>(
});
if (!response.ok) {
if (isUnauthorizedStatus(response.status)) {
handleUnauthorized();
const errorDetails = await parseHostApiError(response);
if (response.status === 401 && isHostApiUnauthorized(errorDetails.code) && allowRetry) {
cachedHostApiToken = null;
return fetchViaLocalHostApi<T>(path, method, headers, body, false);
}
const text = await response.text();
if (!isUnauthorizedStatus(response.status) && isUnauthorizedMessage(text)) {
handleUnauthorized();
}
throw new Error(text || response.statusText || `Request failed with ${response.status}`);
throw new Error(errorDetails.message);
}
const contentType = response.headers.get('content-type') ?? '';
@@ -199,13 +214,7 @@ export async function hostApiFetch<T>(path: string, init?: RequestInitLike): Pro
});
if (!response.ok) {
if (isUnauthorizedStatus(response.status)) {
handleUnauthorized();
}
const text = await response.text();
if (!isUnauthorizedStatus(response.status) && isUnauthorizedMessage(text)) {
handleUnauthorized();
}
throw new Error(text || response.statusText || `Request failed with ${response.status}`);
}
@@ -224,7 +233,7 @@ export async function hostApiFetch<T>(path: string, init?: RequestInitLike): Pro
}
export async function createHostEventSource(path = '/api/events'): Promise<EventSource> {
const token = await getHostApiToken();
const token = await getHostApiToken(true);
const separator = path.includes('?') ? '&' : '?';
return new EventSource(`${HOST_API_BASE}${path}${separator}token=${encodeURIComponent(token)}`);
}

View File

@@ -57,6 +57,7 @@ export interface HostApiResult<T = unknown> {
text?: string;
error?: string;
status?: number;
code?: string;
}
export type GatewayEvent =