Harden auth and security controls with session auth and docs
This commit is contained in:
@@ -1,5 +1,16 @@
|
||||
// @ts-expect-error Node strip-types runtime requires explicit .ts extension in ESM imports.
|
||||
import { API_TOKEN_RUNTIME_GLOBAL_KEY, downloadDocumentContentMarkdown, downloadDocumentFile, getDocumentPreviewBlob, getDocumentThumbnailBlob, setApiTokenResolver, setRuntimeApiToken, updateDocumentMetadata } from './api.ts';
|
||||
// @ts-ignore Node strip-types runtime requires explicit .ts extension in ESM imports.
|
||||
import {
|
||||
downloadDocumentContentMarkdown,
|
||||
downloadDocumentFile,
|
||||
getCurrentAuthSession,
|
||||
getDocumentPreviewBlob,
|
||||
getDocumentThumbnailBlob,
|
||||
getRuntimeApiToken,
|
||||
loginWithPassword,
|
||||
logoutCurrentSession,
|
||||
setRuntimeApiToken,
|
||||
updateDocumentMetadata,
|
||||
} from './api.ts';
|
||||
|
||||
/**
|
||||
* Throws when a test condition is false.
|
||||
@@ -65,12 +76,10 @@ function createMemorySessionStorage(): Storage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs API helper tests for authenticated media and download flows.
|
||||
* Runs API helper tests for authenticated media and auth session workflows.
|
||||
*/
|
||||
async function runApiTests(): Promise<void> {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const runtimeGlobalSource = globalThis as typeof globalThis & Record<string, unknown>;
|
||||
const originalRuntimeGlobalToken = runtimeGlobalSource[API_TOKEN_RUNTIME_GLOBAL_KEY];
|
||||
const sessionStorageDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'sessionStorage');
|
||||
|
||||
try {
|
||||
@@ -79,9 +88,7 @@ async function runApiTests(): Promise<void> {
|
||||
writable: true,
|
||||
value: createMemorySessionStorage(),
|
||||
});
|
||||
setApiTokenResolver(null);
|
||||
setRuntimeApiToken(null);
|
||||
delete runtimeGlobalSource[API_TOKEN_RUNTIME_GLOBAL_KEY];
|
||||
|
||||
const requestUrls: string[] = [];
|
||||
const requestAuthHeaders: Array<string | null> = [];
|
||||
@@ -108,6 +115,7 @@ async function runApiTests(): Promise<void> {
|
||||
assert(requestAuthHeaders[1] === null, `Expected no auth header for preview request, got "${requestAuthHeaders[1]}"`);
|
||||
|
||||
setRuntimeApiToken('session-user-token');
|
||||
assert(getRuntimeApiToken() === 'session-user-token', 'Expected session token readback to match persisted token');
|
||||
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const authHeader = new Headers(init?.headers).get('Authorization');
|
||||
assert(authHeader === 'Bearer session-user-token', `Expected session token auth header, got "${authHeader}"`);
|
||||
@@ -115,16 +123,6 @@ async function runApiTests(): Promise<void> {
|
||||
}) as typeof fetch;
|
||||
await getDocumentPreviewBlob('doc-session-auth');
|
||||
|
||||
setRuntimeApiToken('session-user-token');
|
||||
runtimeGlobalSource[API_TOKEN_RUNTIME_GLOBAL_KEY] = 'runtime-global-token';
|
||||
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const authHeader = new Headers(init?.headers).get('Authorization');
|
||||
assert(authHeader === 'Bearer runtime-global-token', `Expected global runtime token auth header, got "${authHeader}"`);
|
||||
return new Response('preview-bytes', { status: 200 });
|
||||
}) as typeof fetch;
|
||||
await getDocumentPreviewBlob('doc-global-auth');
|
||||
|
||||
setApiTokenResolver(() => 'resolver-token');
|
||||
let mergedContentType: string | null = null;
|
||||
let mergedAuthorization: string | null = null;
|
||||
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
@@ -135,19 +133,47 @@ async function runApiTests(): Promise<void> {
|
||||
}) as typeof fetch;
|
||||
await updateDocumentMetadata('doc-headers', { original_filename: 'renamed.pdf' });
|
||||
assert(mergedContentType === 'application/json', `Expected JSON content type to be preserved, got "${mergedContentType}"`);
|
||||
assert(mergedAuthorization === 'Bearer resolver-token', `Expected resolver token auth header, got "${mergedAuthorization}"`);
|
||||
assert(mergedAuthorization === 'Bearer session-user-token', `Expected auth header, got "${mergedAuthorization}"`);
|
||||
|
||||
setApiTokenResolver(() => ' ');
|
||||
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const authHeader = new Headers(init?.headers).get('Authorization');
|
||||
assert(authHeader === 'Bearer runtime-global-token', `Expected fallback runtime global token auth header, got "${authHeader}"`);
|
||||
return new Response('preview-bytes', { status: 200 });
|
||||
globalThis.fetch = (async (): Promise<Response> => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: 'issued-session-token',
|
||||
token_type: 'bearer',
|
||||
expires_at: '2026-03-01T10:30:00Z',
|
||||
user: {
|
||||
id: '3a42f5e0-b1ad-4f68-b2f4-3fa8c2fb31c9',
|
||||
username: 'admin',
|
||||
role: 'admin',
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}) as typeof fetch;
|
||||
await getDocumentPreviewBlob('doc-resolver-fallback');
|
||||
const loginPayload = await loginWithPassword('admin', 'password');
|
||||
assert(loginPayload.access_token === 'issued-session-token', 'Unexpected issued session token in login payload');
|
||||
assert(loginPayload.user.username === 'admin', 'Unexpected login user payload');
|
||||
|
||||
setApiTokenResolver(null);
|
||||
setRuntimeApiToken(null);
|
||||
delete runtimeGlobalSource[API_TOKEN_RUNTIME_GLOBAL_KEY];
|
||||
globalThis.fetch = (async (): Promise<Response> => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
expires_at: '2026-03-01T10:30:00Z',
|
||||
user: {
|
||||
id: '3a42f5e0-b1ad-4f68-b2f4-3fa8c2fb31c9',
|
||||
username: 'admin',
|
||||
role: 'admin',
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}) as typeof fetch;
|
||||
const sessionPayload = await getCurrentAuthSession();
|
||||
assert(sessionPayload.user.role === 'admin', 'Expected admin role from auth session payload');
|
||||
|
||||
globalThis.fetch = (async (): Promise<Response> => {
|
||||
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}) as typeof fetch;
|
||||
await logoutCurrentSession();
|
||||
|
||||
globalThis.fetch = (async (): Promise<Response> => {
|
||||
return new Response('file-bytes', {
|
||||
@@ -176,13 +202,7 @@ async function runApiTests(): Promise<void> {
|
||||
|
||||
await assertRejects(async () => downloadDocumentContentMarkdown('doc-4'), 'Failed to download document markdown');
|
||||
} finally {
|
||||
setApiTokenResolver(null);
|
||||
setRuntimeApiToken(null);
|
||||
if (originalRuntimeGlobalToken === undefined) {
|
||||
delete runtimeGlobalSource[API_TOKEN_RUNTIME_GLOBAL_KEY];
|
||||
} else {
|
||||
runtimeGlobalSource[API_TOKEN_RUNTIME_GLOBAL_KEY] = originalRuntimeGlobalToken;
|
||||
}
|
||||
if (sessionStorageDescriptor) {
|
||||
Object.defineProperty(globalThis, 'sessionStorage', sessionStorageDescriptor);
|
||||
} else {
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
import type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
AuthLoginResponse,
|
||||
AuthSessionInfo,
|
||||
DocumentListResponse,
|
||||
DmsDocument,
|
||||
DmsDocumentDetail,
|
||||
@@ -33,27 +35,10 @@ function resolveApiBase(): string {
|
||||
|
||||
const API_BASE = resolveApiBase();
|
||||
|
||||
/**
|
||||
* Legacy environment token fallback used only when no runtime token source is available.
|
||||
*/
|
||||
const LEGACY_API_TOKEN = normalizeBearerToken(import.meta.env?.VITE_API_TOKEN);
|
||||
|
||||
/**
|
||||
* Global property name used for runtime token injection.
|
||||
*/
|
||||
export const API_TOKEN_RUNTIME_GLOBAL_KEY = '__DCM_API_TOKEN__';
|
||||
|
||||
/**
|
||||
* Session storage key used for per-user runtime token persistence.
|
||||
*/
|
||||
export const API_TOKEN_RUNTIME_STORAGE_KEY = 'dcm.api_token';
|
||||
|
||||
/**
|
||||
* Resolves a bearer token dynamically at request time.
|
||||
*/
|
||||
export type ApiTokenResolver = () => string | null | undefined;
|
||||
|
||||
let runtimeTokenResolver: ApiTokenResolver | null = null;
|
||||
export const API_TOKEN_RUNTIME_STORAGE_KEY = 'dcm.access_token';
|
||||
|
||||
type ApiRequestInit = Omit<RequestInit, 'headers'> & { headers?: HeadersInit };
|
||||
|
||||
@@ -71,17 +56,9 @@ function normalizeBearerToken(candidate: unknown): string | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves runtime token from mutable global injection points when available.
|
||||
* Resolves bearer token persisted for current browser session.
|
||||
*/
|
||||
function resolveGlobalRuntimeToken(): string | undefined {
|
||||
const source = globalThis as typeof globalThis & Record<string, unknown>;
|
||||
return normalizeBearerToken(source[API_TOKEN_RUNTIME_GLOBAL_KEY]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves runtime token from session storage where per-user state can be isolated by browser session.
|
||||
*/
|
||||
function resolveSessionStorageToken(): string | undefined {
|
||||
export function getRuntimeApiToken(): string | undefined {
|
||||
if (typeof globalThis.sessionStorage === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
@@ -93,31 +70,10 @@ function resolveSessionStorageToken(): string | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves bearer token using runtime sources first, then legacy environment fallback for compatibility.
|
||||
* Resolves bearer token from authenticated browser-session storage.
|
||||
*/
|
||||
function resolveApiToken(): string | undefined {
|
||||
const resolverToken = normalizeBearerToken(runtimeTokenResolver?.());
|
||||
if (resolverToken) {
|
||||
return resolverToken;
|
||||
}
|
||||
const globalRuntimeToken = resolveGlobalRuntimeToken();
|
||||
if (globalRuntimeToken) {
|
||||
return globalRuntimeToken;
|
||||
}
|
||||
const sessionStorageToken = resolveSessionStorageToken();
|
||||
if (sessionStorageToken) {
|
||||
return sessionStorageToken;
|
||||
}
|
||||
return LEGACY_API_TOKEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers or clears a request-time bearer token resolver used by API helpers.
|
||||
*
|
||||
* @param resolver Function returning a token for each request, or `null` to remove custom resolution.
|
||||
*/
|
||||
export function setApiTokenResolver(resolver: ApiTokenResolver | null): void {
|
||||
runtimeTokenResolver = resolver;
|
||||
return getRuntimeApiToken();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -226,6 +182,59 @@ export function downloadBlobFile(blob: Blob, filename: string): void {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticates one user and returns issued bearer token plus role-bound session metadata.
|
||||
*/
|
||||
export async function loginWithPassword(username: string, password: string): Promise<AuthLoginResponse> {
|
||||
const response = await fetch(`${API_BASE}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: username.trim(),
|
||||
password,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const detail = await responseErrorDetail(response);
|
||||
if (detail) {
|
||||
throw new Error(detail);
|
||||
}
|
||||
throw new Error('Login failed');
|
||||
}
|
||||
return response.json() as Promise<AuthLoginResponse>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads currently authenticated user session metadata.
|
||||
*/
|
||||
export async function getCurrentAuthSession(): Promise<AuthSessionInfo> {
|
||||
const response = await apiRequest(`${API_BASE}/auth/me`);
|
||||
if (!response.ok) {
|
||||
const detail = await responseErrorDetail(response);
|
||||
if (detail) {
|
||||
throw new Error(detail);
|
||||
}
|
||||
throw new Error('Failed to load authentication session');
|
||||
}
|
||||
return response.json() as Promise<AuthSessionInfo>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes the current authenticated bearer session.
|
||||
*/
|
||||
export async function logoutCurrentSession(): Promise<void> {
|
||||
const response = await apiRequest(`${API_BASE}/auth/logout`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok && response.status !== 401) {
|
||||
const detail = await responseErrorDetail(response);
|
||||
if (detail) {
|
||||
throw new Error(detail);
|
||||
}
|
||||
throw new Error('Failed to logout');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads documents from the backend list endpoint.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user