fix(dpop): sign XmlHttpReq + refresh

This commit is contained in:
Edouard Vanbelle
2026-08-09 01:39:27 +02:00
parent 82bd2e4d22
commit a7653339b1
3 changed files with 142 additions and 50 deletions
+27 -2
View File
@@ -64,15 +64,40 @@ export async function fetchMe(): Promise<User | null> {
* Attempt a single token refresh (raw fetch, no interceptor). Returns whether * Attempt a single token refresh (raw fetch, no interceptor). Returns whether
* it succeeded. Used by the startup probe; mid-session refresh is handled * it succeeded. Used by the startup probe; mid-session refresh is handled
* transparently by apiFetch for all other endpoints. * 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<boolean> { export async function tryRefresh(): Promise<boolean> {
let dpopMod: typeof import('$lib/auth/dpop-proof') | null = null;
try { 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<Response> => {
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', method: 'POST',
credentials: 'same-origin', credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, headers,
body: '{}' body: '{}'
}); });
if (dpopMod) dpopMod.updateNonceFromResponse(r);
return r;
};
try {
let res = await send();
if (dpopMod && dpopMod.isDpopNonceChallenge(res)) res = await send();
return res.ok; return res.ok;
} catch { } catch {
return false; return false;
+106 -47
View File
@@ -62,61 +62,120 @@ export async function uploadFile(folderId: string | null, file: File): Promise<v
* Upload with progress reporting. `fetch` can't surface upload progress, so this * Upload with progress reporting. `fetch` can't surface upload progress, so this
* uses XHR; CSRF headers are attached the same way as {@link uploadFile}. * uses XHR; CSRF headers are attached the same way as {@link uploadFile}.
* `onProgress` receives a fraction in [0, 1] (or NaN when length is unknown). * `onProgress` receives a fraction in [0, 1] (or NaN when length is unknown).
*
* DPoP proof is minted per attempt and attached as a `DPoP` header, mirroring
* the `apiFetch` interceptor — required for bound sessions under `required`
* mode (server 401s any state-changing call otherwise). Fresh `DPoP-Nonce`
* from the response is pushed into the shared nonce cache so the next
* request (through either apiFetch or another XHR) stays in sync. On a
* `use_dpop_nonce` challenge the upload is retried ONCE with the freshly-
* harvested nonce.
*/ */
export function uploadFileWithProgress( export async function uploadFileWithProgress(
folderId: string | null, folderId: string | null,
file: File, file: File,
onProgress: (fraction: number) => void onProgress: (fraction: number) => void
): Promise<void> { ): Promise<void> {
return new Promise((resolve, reject) => { // Dynamic import — falls back to a headerless XHR if the DPoP module
const form = new FormData(); // isn't loadable (SubtleCrypto disabled, IndexedDB blocked, etc.).
if (folderId) form.append('folder_id', folderId); // Bound sessions in `required` mode still 401, but that's the fail-
form.append('file', file); // open contract already documented for other DPoP-aware raw callers
const xhr = new XMLHttpRequest(); // (`fetchMe`).
xhr.open('POST', '/api/files/upload'); let dpopMod: typeof import('$lib/auth/dpop-proof') | null = null;
xhr.withCredentials = true; try {
for (const [k, v] of Object.entries(getCsrfHeaders())) xhr.setRequestHeader(k, v); 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 const attempt = (): Promise<void> =>
// slot forever (and leave a zombie XHR holding one of the browser's few new Promise((resolve, reject) => {
// per-host connections). While the body is uploading we reset the deadline const form = new FormData();
// on every progress tick — a slow but *moving* transfer is fine; once the if (folderId) form.append('folder_id', folderId);
// body is fully sent we give the server a fixed window to respond. On a form.append('file', file);
// stall we `xhr.abort()`, which frees the connection immediately. const xhr = new XMLHttpRequest();
const SEND_STALL_MS = 30_000; xhr.open('POST', '/api/files/upload');
const RESPONSE_MS = 60_000; xhr.withCredentials = true;
let watchdog: ReturnType<typeof setTimeout>; for (const [k, v] of Object.entries(getCsrfHeaders())) xhr.setRequestHeader(k, v);
const arm = (ms: number) => {
clearTimeout(watchdog);
watchdog = setTimeout(() => xhr.abort(), ms);
};
xhr.upload.onprogress = (e) => { // Self-aborting watchdog so a stalled connection can never pin an upload
onProgress(e.lengthComputable ? e.loaded / e.total : NaN); // slot forever (and leave a zombie XHR holding one of the browser's few
arm(SEND_STALL_MS); // 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
xhr.upload.onload = () => arm(RESPONSE_MS); // body sent — wait for the server // body is fully sent we give the server a fixed window to respond. On a
xhr.onload = () => { // stall we `xhr.abort()`, which frees the connection immediately.
clearTimeout(watchdog); const SEND_STALL_MS = 30_000;
if (xhr.status >= 200 && xhr.status < 300) resolve(); const RESPONSE_MS = 60_000;
else { let watchdog: ReturnType<typeof setTimeout>;
// Flag quota so a batch can stop early instead of retrying every file. const arm = (ms: number) => {
const err = new Error(`upload failed: ${xhr.status}`) as Error & { isQuota?: boolean }; clearTimeout(watchdog);
err.isQuota = xhr.status === 507; watchdog = setTimeout(() => xhr.abort(), ms);
reject(err); };
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); try {
reject(new Error('upload failed: network error')); await attempt();
}; } catch (err) {
xhr.onabort = () => { if ((err as { isNonceChallenge?: boolean } | null)?.isNonceChallenge) {
clearTimeout(watchdog); // Nonce was harvested by the failed attempt's onload; retry ONCE.
reject(new Error('upload stalled — aborted')); // A second challenge would loop, so any further failure surfaces.
}; await attempt();
arm(SEND_STALL_MS); return;
xhr.send(form); }
}); throw err;
}
} }
export async function renameFile(fileId: string, name: string): Promise<void> { export async function renameFile(fileId: string, name: string): Promise<void> {
+9 -1
View File
@@ -37,7 +37,15 @@ function loadNonceOnce(): void {
/** Update the nonce state from a fresh `DPoP-Nonce` response header. */ /** Update the nonce state from a fresh `DPoP-Nonce` response header. */
export function updateNonceFromResponse(response: Response): void { 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; if (!fresh || fresh === currentNonce) return;
currentNonce = fresh; currentNonce = fresh;
try { try {