Harden auth and security controls with session auth and docs

This commit is contained in:
2026-03-01 15:29:09 -03:00
parent 7a19f22f41
commit 0242e061c2
36 changed files with 1794 additions and 505 deletions

View File

@@ -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 {

View File

@@ -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.
*/