Fix auth session persistence with HttpOnly cookies and CSRF

This commit is contained in:
2026-03-01 21:39:22 -03:00
parent a9333ec973
commit 26eae1a09b
14 changed files with 255 additions and 108 deletions

View File

@@ -5,10 +5,8 @@ import {
getCurrentAuthSession,
getDocumentPreviewBlob,
getDocumentThumbnailBlob,
getRuntimeApiToken,
loginWithPassword,
logoutCurrentSession,
setRuntimeApiToken,
updateDocumentMetadata,
} from './api.ts';
@@ -53,15 +51,18 @@ function toRequestUrl(input: RequestInfo | URL): string {
*/
async function runApiTests(): Promise<void> {
const originalFetch = globalThis.fetch;
const globalWithDocument = globalThis as typeof globalThis & { document?: { cookie?: string } };
const originalDocument = globalWithDocument.document;
try {
setRuntimeApiToken(null);
const requestUrls: string[] = [];
const requestAuthHeaders: Array<string | null> = [];
const requestCsrfHeaders: Array<string | null> = [];
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
requestUrls.push(toRequestUrl(input));
requestAuthHeaders.push(new Headers(init?.headers).get('Authorization'));
const normalizedHeaders = new Headers(init?.headers);
requestAuthHeaders.push(normalizedHeaders.get('Authorization'));
requestCsrfHeaders.push(normalizedHeaders.get('x-csrf-token'));
return new Response('preview-bytes', { status: 200 });
}) as typeof fetch;
@@ -80,27 +81,26 @@ async function runApiTests(): Promise<void> {
);
assert(requestAuthHeaders[0] === null, `Expected no auth header for thumbnail request, got "${requestAuthHeaders[0]}"`);
assert(requestAuthHeaders[1] === null, `Expected no auth header for preview request, got "${requestAuthHeaders[1]}"`);
assert(requestCsrfHeaders[0] === null, `Expected no CSRF header for thumbnail request, got "${requestCsrfHeaders[0]}"`);
assert(requestCsrfHeaders[1] === null, `Expected no CSRF header for preview request, got "${requestCsrfHeaders[1]}"`);
setRuntimeApiToken('session-user-token');
assert(getRuntimeApiToken() === 'session-user-token', 'Expected runtime token readback to match active 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}"`);
return new Response('preview-bytes', { status: 200 });
}) as typeof fetch;
await getDocumentPreviewBlob('doc-session-auth');
let mergedContentType: string | null = null;
let mergedAuthorization: string | null = null;
globalWithDocument.document = {
cookie: 'dcm_csrf=csrf-session-token',
};
let metadataCsrfHeader: string | null = null;
let metadataContentType: string | null = null;
let metadataAuthHeader: string | null = null;
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const headers = new Headers(init?.headers);
mergedContentType = headers.get('Content-Type');
mergedAuthorization = headers.get('Authorization');
metadataCsrfHeader = headers.get('x-csrf-token');
metadataAuthHeader = headers.get('Authorization');
metadataContentType = headers.get('Content-Type');
return new Response('{}', { status: 200 });
}) 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 session-user-token', `Expected auth header, got "${mergedAuthorization}"`);
assert(metadataContentType === 'application/json', `Expected JSON content type to be preserved, got "${metadataContentType}"`);
assert(metadataAuthHeader === null, `Expected no auth header, got "${metadataAuthHeader}"`);
assert(metadataCsrfHeader === 'csrf-session-token', `Expected CSRF header, got "${metadataCsrfHeader}"`);
globalThis.fetch = (async (): Promise<Response> => {
return new Response(
@@ -169,8 +169,12 @@ async function runApiTests(): Promise<void> {
await assertRejects(async () => downloadDocumentContentMarkdown('doc-4'), 'Failed to download document markdown');
} finally {
setRuntimeApiToken(null);
globalThis.fetch = originalFetch;
if (originalDocument !== undefined) {
globalWithDocument.document = originalDocument;
} else {
delete globalWithDocument.document;
}
}
}

View File

@@ -36,73 +36,69 @@ function resolveApiBase(): string {
const API_BASE = resolveApiBase();
/**
* In-memory bearer token scoped to the active frontend runtime.
*
* This value is intentionally not persisted to browser storage.
* CSRF cookie contract used by authenticated requests.
*/
let runtimeApiToken: string | undefined;
const CSRF_COOKIE_NAME = "dcm_csrf";
const CSRF_HEADER_NAME = "x-csrf-token";
const CSRF_SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
type ApiRequestInit = Omit<RequestInit, 'headers'> & { headers?: HeadersInit };
type ApiErrorPayload = { detail?: string } | null;
/**
* Normalizes candidate token values by trimming whitespace and filtering non-string values.
* Returns a cookie value by name for the active browser runtime.
*/
function normalizeBearerToken(candidate: unknown): string | undefined {
if (typeof candidate !== 'string') {
function getCookieValue(name: string): string | undefined {
if (typeof document === "undefined") {
return undefined;
}
const normalized = candidate.trim();
return normalized ? normalized : undefined;
const rawCookie = document.cookie ?? "";
return rawCookie
.split(";")
.map((entry) => entry.trim())
.find((entry) => entry.startsWith(`${name}=`))
?.slice(name.length + 1);
}
/**
* Resolves bearer token for the active browser runtime.
* Resolves the runtime CSRF token from browser cookie storage for API requests.
*/
export function getRuntimeApiToken(): string | undefined {
return runtimeApiToken;
function resolveCsrfToken(): string | undefined {
return getCookieValue(CSRF_COOKIE_NAME);
}
/**
* Resolves bearer token from active runtime memory.
* Returns whether a method should include CSRF metadata.
*/
function resolveApiToken(): string | undefined {
return getRuntimeApiToken();
function requiresCsrfHeader(method: string): boolean {
const normalizedMethod = method.toUpperCase();
return !CSRF_SAFE_METHODS.has(normalizedMethod);
}
/**
* Stores or clears the per-user runtime API token in memory.
*
* @param token Token value for the active runtime; clears token when empty.
* Merges request headers and appends CSRF metadata for state-changing requests.
*/
export function setRuntimeApiToken(token: string | null | undefined): void {
runtimeApiToken = normalizeBearerToken(token);
}
/**
* Merges request headers and appends bearer authorization when a token can be resolved.
*/
function buildRequestHeaders(headers?: HeadersInit): Headers | undefined {
const apiToken = resolveApiToken();
if (!apiToken && !headers) {
return undefined;
}
function buildRequestHeaders(method: string, headers?: HeadersInit): Headers | undefined {
const requestHeaders = new Headers(headers);
if (apiToken) {
requestHeaders.set('Authorization', `Bearer ${apiToken}`);
if (method && requiresCsrfHeader(method)) {
const csrfToken = resolveCsrfToken();
if (csrfToken) {
requestHeaders.set(CSRF_HEADER_NAME, csrfToken);
}
}
return requestHeaders;
}
/**
* Executes an API request with centralized auth-header handling.
* Executes an API request with shared fetch options and CSRF handling.
*/
function apiRequest(input: string, init: ApiRequestInit = {}): Promise<Response> {
const headers = buildRequestHeaders(init.headers);
const method = init.method ?? "GET";
const headers = buildRequestHeaders(method, init.headers);
return fetch(input, {
...init,
credentials: 'include',
...(headers ? { headers } : {}),
});
}
@@ -166,11 +162,12 @@ export function downloadBlobFile(blob: Blob, filename: string): void {
}
/**
* Authenticates one user and returns issued bearer token plus role-bound session metadata.
* Authenticates one user and returns authenticated session metadata.
*/
export async function loginWithPassword(username: string, password: string): Promise<AuthLoginResponse> {
const response = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: username.trim(),
@@ -203,7 +200,7 @@ export async function getCurrentAuthSession(): Promise<AuthSessionInfo> {
}
/**
* Revokes the current authenticated bearer session.
* Revokes the current authenticated session.
*/
export async function logoutCurrentSession(): Promise<void> {
const response = await apiRequest(`${API_BASE}/auth/logout`, {