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

@@ -20,7 +20,6 @@ import {
deleteDocument,
exportContentsMarkdown,
getCurrentAuthSession,
getRuntimeApiToken,
getAppSettings,
listDocuments,
listPaths,
@@ -30,7 +29,6 @@ import {
loginWithPassword,
logoutCurrentSession,
resetAppSettings,
setRuntimeApiToken,
searchDocuments,
trashDocument,
updateAppSettings,
@@ -161,21 +159,19 @@ export default function App(): JSX.Element {
}, []);
/**
* Exchanges submitted credentials for server-issued bearer session and activates app shell.
* Exchanges submitted credentials for a server-issued session and activates the app shell.
*/
const handleLogin = useCallback(async (username: string, password: string): Promise<void> => {
setIsAuthenticating(true);
setAuthError(null);
try {
const payload = await loginWithPassword(username, password);
setRuntimeApiToken(payload.access_token);
setAuthUser(payload.user);
setAuthPhase('authenticated');
setError(null);
} catch (caughtError) {
const message = caughtError instanceof Error ? caughtError.message : 'Login failed';
setAuthError(message);
setRuntimeApiToken(null);
setAuthUser(null);
setAuthPhase('unauthenticated');
resetApplicationState();
@@ -192,7 +188,6 @@ export default function App(): JSX.Element {
try {
await logoutCurrentSession();
} catch {}
setRuntimeApiToken(null);
setAuthUser(null);
setAuthError(null);
setAuthPhase('unauthenticated');
@@ -303,13 +298,6 @@ export default function App(): JSX.Element {
}, [isAdmin]);
useEffect(() => {
const existingToken = getRuntimeApiToken();
if (!existingToken) {
setAuthPhase('unauthenticated');
setAuthUser(null);
return;
}
const resolveSession = async (): Promise<void> => {
try {
const sessionPayload = await getCurrentAuthSession();
@@ -317,7 +305,6 @@ export default function App(): JSX.Element {
setAuthError(null);
setAuthPhase('authenticated');
} catch {
setRuntimeApiToken(null);
setAuthUser(null);
setAuthPhase('unauthenticated');
resetApplicationState();

View File

@@ -11,7 +11,7 @@ interface LoginScreenProps {
}
/**
* Renders credential form used to issue per-user API bearer sessions.
* Renders credential form used to issue per-user API sessions.
*/
export default function LoginScreen({
error,

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`, {

View File

@@ -76,11 +76,12 @@ export interface AuthSessionInfo {
}
/**
* Represents login response payload with issued bearer token and session metadata.
* Represents login response payload with issued session metadata.
*/
export interface AuthLoginResponse extends AuthSessionInfo {
access_token: string;
access_token?: string;
token_type: 'bearer';
csrf_token?: string;
}
/**