feat: enhance host API authentication handling and add regression tests
This commit is contained in:
@@ -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)}`);
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ export interface HostApiResult<T = unknown> {
|
||||
text?: string;
|
||||
error?: string;
|
||||
status?: number;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export type GatewayEvent =
|
||||
|
||||
Reference in New Issue
Block a user