diff --git a/frontend/src/lib/api/endpoints/auth.ts b/frontend/src/lib/api/endpoints/auth.ts index d50408c2..23a4c8ef 100644 --- a/frontend/src/lib/api/endpoints/auth.ts +++ b/frontend/src/lib/api/endpoints/auth.ts @@ -64,15 +64,40 @@ export async function fetchMe(): Promise { * Attempt a single token refresh (raw fetch, no interceptor). Returns whether * it succeeded. Used by the startup probe; mid-session refresh is handled * transparently by apiFetch for all other endpoints. + * + * Mirrors `fetchMe`'s DPoP handling: dynamic-imports the proof module and + * attaches a signed proof so a bound session under `required` mode can still + * refresh on page reload. Falls back to a headerless refresh if the module + * is unavailable (unbound sessions still succeed; bound sessions in required + * mode won't — the documented fail-open contract in `docs/plan/dpop.md`). + * Retries ONCE on a `use_dpop_nonce` challenge so the very first request + * after a page load can adopt the freshly-issued nonce. */ export async function tryRefresh(): Promise { + let dpopMod: typeof import('$lib/auth/dpop-proof') | null = null; try { - const res = await fetch('/api/auth/refresh', { + dpopMod = await import('$lib/auth/dpop-proof'); + } catch { + /* no dpop module → plain fetch */ + } + const url = `${location.origin}/api/auth/refresh`; + const send = async (): Promise => { + const proof = dpopMod ? await dpopMod.buildDpopProof('POST', url).catch(() => null) : null; + const headers: HeadersInit = proof + ? { ...JSON_HEADERS, ...getCsrfHeaders(), DPoP: proof } + : { ...JSON_HEADERS, ...getCsrfHeaders() }; + const r = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'same-origin', - headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, + headers, body: '{}' }); + if (dpopMod) dpopMod.updateNonceFromResponse(r); + return r; + }; + try { + let res = await send(); + if (dpopMod && dpopMod.isDpopNonceChallenge(res)) res = await send(); return res.ok; } catch { return false; diff --git a/frontend/src/lib/api/endpoints/files.ts b/frontend/src/lib/api/endpoints/files.ts index fd6e37db..b233e4b5 100644 --- a/frontend/src/lib/api/endpoints/files.ts +++ b/frontend/src/lib/api/endpoints/files.ts @@ -62,61 +62,120 @@ export async function uploadFile(folderId: string | null, file: File): Promise void ): Promise { - return new Promise((resolve, reject) => { - const form = new FormData(); - if (folderId) form.append('folder_id', folderId); - form.append('file', file); - const xhr = new XMLHttpRequest(); - xhr.open('POST', '/api/files/upload'); - xhr.withCredentials = true; - for (const [k, v] of Object.entries(getCsrfHeaders())) xhr.setRequestHeader(k, v); + // Dynamic import — falls back to a headerless XHR if the DPoP module + // isn't loadable (SubtleCrypto disabled, IndexedDB blocked, etc.). + // Bound sessions in `required` mode still 401, but that's the fail- + // open contract already documented for other DPoP-aware raw callers + // (`fetchMe`). + let dpopMod: typeof import('$lib/auth/dpop-proof') | null = null; + try { + dpopMod = await import('$lib/auth/dpop-proof'); + } catch { + /* no dpop module → plain XHR */ + } + const url = `${location.origin}/api/files/upload`; - // Self-aborting watchdog so a stalled connection can never pin an upload - // slot forever (and leave a zombie XHR holding one of the browser's few - // per-host connections). While the body is uploading we reset the deadline - // on every progress tick — a slow but *moving* transfer is fine; once the - // body is fully sent we give the server a fixed window to respond. On a - // stall we `xhr.abort()`, which frees the connection immediately. - const SEND_STALL_MS = 30_000; - const RESPONSE_MS = 60_000; - let watchdog: ReturnType; - const arm = (ms: number) => { - clearTimeout(watchdog); - watchdog = setTimeout(() => xhr.abort(), ms); - }; + const attempt = (): Promise => + new Promise((resolve, reject) => { + const form = new FormData(); + if (folderId) form.append('folder_id', folderId); + form.append('file', file); + const xhr = new XMLHttpRequest(); + xhr.open('POST', '/api/files/upload'); + xhr.withCredentials = true; + for (const [k, v] of Object.entries(getCsrfHeaders())) xhr.setRequestHeader(k, v); - xhr.upload.onprogress = (e) => { - onProgress(e.lengthComputable ? e.loaded / e.total : NaN); - arm(SEND_STALL_MS); - }; - xhr.upload.onload = () => arm(RESPONSE_MS); // body sent — wait for the server - xhr.onload = () => { - clearTimeout(watchdog); - if (xhr.status >= 200 && xhr.status < 300) resolve(); - else { - // Flag quota so a batch can stop early instead of retrying every file. - const err = new Error(`upload failed: ${xhr.status}`) as Error & { isQuota?: boolean }; - err.isQuota = xhr.status === 507; - reject(err); + // Self-aborting watchdog so a stalled connection can never pin an upload + // slot forever (and leave a zombie XHR holding one of the browser's few + // per-host connections). While the body is uploading we reset the deadline + // on every progress tick — a slow but *moving* transfer is fine; once the + // body is fully sent we give the server a fixed window to respond. On a + // stall we `xhr.abort()`, which frees the connection immediately. + const SEND_STALL_MS = 30_000; + const RESPONSE_MS = 60_000; + let watchdog: ReturnType; + const arm = (ms: number) => { + clearTimeout(watchdog); + watchdog = setTimeout(() => xhr.abort(), ms); + }; + + const doSend = (proof: string | null) => { + if (proof) xhr.setRequestHeader('DPoP', proof); + xhr.upload.onprogress = (e) => { + onProgress(e.lengthComputable ? e.loaded / e.total : NaN); + arm(SEND_STALL_MS); + }; + xhr.upload.onload = () => arm(RESPONSE_MS); // body sent — wait for the server + xhr.onload = () => { + clearTimeout(watchdog); + // Sync the shared nonce cache from the response — the server + // rotates the nonce on every response, and other callers + // (apiFetch, fetchMe) share the same in-memory store. + if (dpopMod) dpopMod.updateNonceFromHeader(xhr.getResponseHeader('DPoP-Nonce')); + // Nonce challenge → surface a distinctive rejection so the outer + // retry can re-arm a fresh XHR (the current one has already + // consumed its request body). + if (xhr.status === 401 && /use_dpop_nonce/i.test(xhr.getResponseHeader('WWW-Authenticate') ?? '')) { + const err = new Error('dpop_nonce_challenge') as Error & { isNonceChallenge?: boolean }; + err.isNonceChallenge = true; + reject(err); + return; + } + if (xhr.status >= 200 && xhr.status < 300) resolve(); + else { + // Flag quota so a batch can stop early instead of retrying every file. + const err = new Error(`upload failed: ${xhr.status}`) as Error & { isQuota?: boolean }; + err.isQuota = xhr.status === 507; + reject(err); + } + }; + xhr.onerror = () => { + clearTimeout(watchdog); + reject(new Error('upload failed: network error')); + }; + xhr.onabort = () => { + clearTimeout(watchdog); + reject(new Error('upload stalled — aborted')); + }; + arm(SEND_STALL_MS); + xhr.send(form); + }; + + if (dpopMod) { + dpopMod + .buildDpopProof('POST', url) + .catch(() => null) + .then(doSend); + } else { + doSend(null); } - }; - xhr.onerror = () => { - clearTimeout(watchdog); - reject(new Error('upload failed: network error')); - }; - xhr.onabort = () => { - clearTimeout(watchdog); - reject(new Error('upload stalled — aborted')); - }; - arm(SEND_STALL_MS); - xhr.send(form); - }); + }); + + try { + await attempt(); + } catch (err) { + if ((err as { isNonceChallenge?: boolean } | null)?.isNonceChallenge) { + // Nonce was harvested by the failed attempt's onload; retry ONCE. + // A second challenge would loop, so any further failure surfaces. + await attempt(); + return; + } + throw err; + } } export async function renameFile(fileId: string, name: string): Promise { diff --git a/frontend/src/lib/auth/dpop-proof.ts b/frontend/src/lib/auth/dpop-proof.ts index 72610b08..2c74a5cb 100644 --- a/frontend/src/lib/auth/dpop-proof.ts +++ b/frontend/src/lib/auth/dpop-proof.ts @@ -37,7 +37,15 @@ function loadNonceOnce(): void { /** Update the nonce state from a fresh `DPoP-Nonce` response header. */ export function updateNonceFromResponse(response: Response): void { - const fresh = response.headers.get('DPoP-Nonce'); + updateNonceFromHeader(response.headers.get('DPoP-Nonce')); +} + +/** + * Update the nonce state from a raw header value — for callers that + * don't have a `fetch` `Response` (e.g. the `XMLHttpRequest` upload + * path, which needs XHR for upload-progress events). + */ +export function updateNonceFromHeader(fresh: string | null): void { if (!fresh || fresh === currentNonce) return; currentNonce = fresh; try {