Harden frontend auth token handling in runtime memory
This commit is contained in:
@@ -52,7 +52,8 @@ Do not hardcode new palette or spacing values in component styles when a token a
|
||||
## Authenticated Media Delivery
|
||||
|
||||
- Document previews and thumbnails must load through authenticated fetch flows in `frontend/src/lib/api.ts`, then render via temporary object URLs.
|
||||
- Runtime auth uses server-issued per-user session tokens persisted with `setRuntimeApiToken` and read by `getRuntimeApiToken`.
|
||||
- Runtime auth keeps server-issued per-user session tokens only in active-tab memory via `setRuntimeApiToken` and `getRuntimeApiToken`.
|
||||
- Users must sign in again after a full browser reload, new tab launch, or browser restart because tokens are not persisted in browser storage.
|
||||
- Static build-time token distribution is not supported.
|
||||
- Direct `window.open` calls for protected media endpoints are not allowed because browser navigation requests do not include the API token header.
|
||||
- Download actions for original files and markdown exports must use authenticated blob fetches plus controlled browser download triggers.
|
||||
|
||||
@@ -56,7 +56,7 @@ docker compose logs -f
|
||||
- `AUTH_LOGIN_FAILURE_WINDOW_SECONDS`
|
||||
- `AUTH_LOGIN_LOCKOUT_BASE_SECONDS`
|
||||
- `AUTH_LOGIN_LOCKOUT_MAX_SECONDS`
|
||||
- Frontend signs in through `/api/v1/auth/login` and stores issued session token in browser session storage.
|
||||
- Frontend signs in through `/api/v1/auth/login` and keeps issued session token only in active runtime memory.
|
||||
|
||||
## DEV And LIVE Configuration Matrix
|
||||
|
||||
@@ -119,7 +119,8 @@ Recommended LIVE pattern:
|
||||
## Frontend Runtime
|
||||
|
||||
- Frontend no longer consumes `VITE_API_TOKEN`.
|
||||
- Session token storage key is `dcm.access_token` in browser session storage.
|
||||
- Session tokens are not persisted to browser storage.
|
||||
- Users must sign in again after full page reload, opening a new tab, or browser restart.
|
||||
- Protected media and file download flows still use authenticated fetch plus blob/object URL handling.
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
@@ -48,46 +48,13 @@ function toRequestUrl(input: RequestInfo | URL): string {
|
||||
return input.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a minimal session storage implementation for Node-based tests.
|
||||
*/
|
||||
function createMemorySessionStorage(): Storage {
|
||||
const values = new Map<string, string>();
|
||||
return {
|
||||
get length(): number {
|
||||
return values.size;
|
||||
},
|
||||
clear(): void {
|
||||
values.clear();
|
||||
},
|
||||
getItem(key: string): string | null {
|
||||
return values.has(key) ? values.get(key) ?? null : null;
|
||||
},
|
||||
key(index: number): string | null {
|
||||
return Array.from(values.keys())[index] ?? null;
|
||||
},
|
||||
removeItem(key: string): void {
|
||||
values.delete(key);
|
||||
},
|
||||
setItem(key: string, value: string): void {
|
||||
values.set(key, String(value));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs API helper tests for authenticated media and auth session workflows.
|
||||
*/
|
||||
async function runApiTests(): Promise<void> {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const sessionStorageDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'sessionStorage');
|
||||
|
||||
try {
|
||||
Object.defineProperty(globalThis, 'sessionStorage', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: createMemorySessionStorage(),
|
||||
});
|
||||
setRuntimeApiToken(null);
|
||||
|
||||
const requestUrls: string[] = [];
|
||||
@@ -115,7 +82,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');
|
||||
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}"`);
|
||||
@@ -203,11 +170,6 @@ async function runApiTests(): Promise<void> {
|
||||
await assertRejects(async () => downloadDocumentContentMarkdown('doc-4'), 'Failed to download document markdown');
|
||||
} finally {
|
||||
setRuntimeApiToken(null);
|
||||
if (sessionStorageDescriptor) {
|
||||
Object.defineProperty(globalThis, 'sessionStorage', sessionStorageDescriptor);
|
||||
} else {
|
||||
delete (globalThis as { sessionStorage?: Storage }).sessionStorage;
|
||||
}
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,9 +36,11 @@ function resolveApiBase(): string {
|
||||
const API_BASE = resolveApiBase();
|
||||
|
||||
/**
|
||||
* Session storage key used for per-user runtime token persistence.
|
||||
* In-memory bearer token scoped to the active frontend runtime.
|
||||
*
|
||||
* This value is intentionally not persisted to browser storage.
|
||||
*/
|
||||
export const API_TOKEN_RUNTIME_STORAGE_KEY = 'dcm.access_token';
|
||||
let runtimeApiToken: string | undefined;
|
||||
|
||||
type ApiRequestInit = Omit<RequestInit, 'headers'> & { headers?: HeadersInit };
|
||||
|
||||
@@ -56,45 +58,26 @@ function normalizeBearerToken(candidate: unknown): string | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves bearer token persisted for current browser session.
|
||||
* Resolves bearer token for the active browser runtime.
|
||||
*/
|
||||
export function getRuntimeApiToken(): string | undefined {
|
||||
if (typeof globalThis.sessionStorage === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return normalizeBearerToken(globalThis.sessionStorage.getItem(API_TOKEN_RUNTIME_STORAGE_KEY));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return runtimeApiToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves bearer token from authenticated browser-session storage.
|
||||
* Resolves bearer token from active runtime memory.
|
||||
*/
|
||||
function resolveApiToken(): string | undefined {
|
||||
return getRuntimeApiToken();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores or clears the per-user runtime API token in session storage.
|
||||
* Stores or clears the per-user runtime API token in memory.
|
||||
*
|
||||
* @param token Token value to persist for this browser session; clears persisted token when empty.
|
||||
* @param token Token value for the active runtime; clears token when empty.
|
||||
*/
|
||||
export function setRuntimeApiToken(token: string | null | undefined): void {
|
||||
if (typeof globalThis.sessionStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const normalized = normalizeBearerToken(token);
|
||||
if (normalized) {
|
||||
globalThis.sessionStorage.setItem(API_TOKEN_RUNTIME_STORAGE_KEY, normalized);
|
||||
return;
|
||||
}
|
||||
globalThis.sessionStorage.removeItem(API_TOKEN_RUNTIME_STORAGE_KEY);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
runtimeApiToken = normalizeBearerToken(token);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user