Fix auth session persistence with HttpOnly cookies and CSRF
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user