diff --git a/frontend/src/lib/api/endpoints/auth.ts b/frontend/src/lib/api/endpoints/auth.ts index 23a4c8ef..14c73d38 100644 --- a/frontend/src/lib/api/endpoints/auth.ts +++ b/frontend/src/lib/api/endpoints/auth.ts @@ -46,15 +46,38 @@ const JSON_HEADERS = { 'Content-Type': 'application/json' }; * headerless request — the server still accepts it for unbound sessions. */ export async function fetchMe(): Promise { - let dpop: string | null = null; + // Build + sign a DPoP proof, send with the header, harvest any + // `DPoP-Nonce` off the response into the shared client cache + // (so the NEXT apiFetch call reuses it — no wasted round trip). + // Handle the `use_dpop_nonce` challenge inline: the first request + // per fresh session has no cached nonce, and Gate 9 required-mode + // middleware 401-challenges a bound session's very first proof so + // the client picks up a fresh nonce. Without this retry, `/api/auth/me` + // on a fresh page load would always 401 → SPA thinks user isn't + // logged in → stuck on /login even though cookies are valid. + // + // Falls back to a plain fetch when the DPoP module is unavailable + // (SubtleCrypto disabled, IndexedDB blocked): unbound sessions + // still authenticate; bound sessions in required mode won't, but + // that's the fail-open contract from `docs/plan/dpop.md`. + let dpopMod: typeof import('$lib/auth/dpop-proof') | null = null; try { - const { buildDpopProof } = await import('$lib/auth/dpop-proof'); - dpop = await buildDpopProof('GET', `${location.origin}/api/auth/me`); + dpopMod = await import('$lib/auth/dpop-proof'); } catch { - /* proof unavailable → send without header; unbound sessions still accept */ + /* no dpop module → plain fetch */ } - const headers: HeadersInit = dpop ? { DPoP: dpop } : {}; - const res = await fetch('/api/auth/me', { credentials: 'same-origin', headers }); + const url = `${location.origin}/api/auth/me`; + const send = async (): Promise => { + const proof = dpopMod ? await dpopMod.buildDpopProof('GET', url).catch(() => null) : null; + const headers: HeadersInit = proof ? { DPoP: proof } : {}; + const r = await fetch('/api/auth/me', { credentials: 'same-origin', headers }); + if (dpopMod) dpopMod.updateNonceFromResponse(r); + return r; + }; + let res = await send(); + // One retry on nonce challenge — mirror the apiFetch interceptor. + // A second challenge on the retry is a server bug; surface the 401. + if (dpopMod && dpopMod.isDpopNonceChallenge(res)) res = await send(); if (res.status === 401) return null; if (!res.ok) throw new Error(`/api/auth/me failed: ${res.status}`); return (await res.json()) as User; diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index f11a1547..59fa353c 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -66,14 +66,19 @@ export default defineConfig({ // Verbose startup so a CI webServer-readiness timeout shows where the // server stalls (DB connect, migrations, bind) instead of nothing. RUST_LOG: 'info,oxicloud=debug,sqlx=warn,tower_http=info', - // OPAQUE + DPoP are inherited from `../common/server.env`: + // OPAQUE + DPoP inherited from `../common/server.env`: // OXICLOUD_AUTH_OPAQUE_MODE=migrate (Phase 2 silent-migration // on first legacy login, Phase 4 refusal thereafter) - // OXICLOUD_DPOP_MODE=required (verify every proof; unbound - // sessions still exempt per Gate 5 design) - // Testing under the production shape catches breakage where the - // SPA's fetch interceptor or the migration hook regresses in - // ways that only surface in a real browser + real crypto. + // OXICLOUD_DPOP_MODE=required (verify every proof; + // unbound sessions still exempt per Gate 5 design) + // + // Known failure surfaces under `DPOP=required`: + // * Node-side `page.request.*` helpers can't sign proofs + // → 401 on state-changing calls. Task #47 rewrites those + // through `page.evaluate` so signing happens in-browser. + // * Browser-direct content GETs (img src, a href, video src) + // also can't sign — Gate C content-serve allowlist in + // `middleware/dpop.rs` exempts the known paths. }, }, }); diff --git a/tests/e2e/playwright.coverage.config.ts b/tests/e2e/playwright.coverage.config.ts index f25ffb5c..269b4d00 100644 --- a/tests/e2e/playwright.coverage.config.ts +++ b/tests/e2e/playwright.coverage.config.ts @@ -73,6 +73,27 @@ export default defineConfig({ // `effective_mode == Off` short-circuit path. OXICLOUD_AUTH_OPAQUE_MODE: 'off', OXICLOUD_AUTH_OPAQUE_SERVER_SETUP: '', + // DPoP `opportunistic` — SPA browser flows still exercise the + // full wire protocol (proof signing + server verification + + // nonce challenge/retry + replay cache). The only weakening + // vs production `required` is that BOUND session + MISSING + // proof gets a warning-only pass instead of 401. + // + // Why not required: Node-side `page.request.*` test helpers + // (apiCreateFolder, apiAdminCreateUser, apiUploadFile, …) + // can't sign DPoP proofs because the browser's keypair is + // non-extractable by design. Under `required`, every helper + // POST/PUT/DELETE 401s and most tests fail at beforeEach. + // + // The missing-proof-on-bound-session enforcement IS covered + // end-to-end by `dpop-hurl-helper` scenario 9 under + // `tests/api/run.sh` (which keeps required from server.env), + // so global enforcement coverage is preserved. + // + // Task #47 tracks rewriting the helpers through page.evaluate + // so they can sign proofs in-browser. Once landed, this + // override goes and Playwright runs production-shape. + OXICLOUD_DPOP_MODE: 'opportunistic', }, }, }); diff --git a/tests/e2e/scenarios/helpers.ts b/tests/e2e/scenarios/helpers.ts index c3703b8e..ec1721f1 100644 --- a/tests/e2e/scenarios/helpers.ts +++ b/tests/e2e/scenarios/helpers.ts @@ -142,17 +142,38 @@ export async function apiLogin(page: Page, admin = TEST_ADMIN): Promise { } await page.goto('/login'); - await page.locator('[data-testid="login-username-input"]').fill(admin.username); - await page.locator('[data-testid="login-password-input"]').fill(admin.password); - await page.locator('[data-testid="login-submit-btn"]').click(); + // Wait for the SPA's boot probes (`getOidcProviders` + + // `getAuthStatus` in `login/+page.svelte::onMount`) to complete + // BEFORE touching the form. Otherwise the boot `$effect` fires + // MID-FILL — when `booting` flips from true to false, the + // auto-focus effect steals focus back to the identifier input, + // and any remaining characters of the password-fill land in + // the username field. Symptom: username="adminTestPassword1!", + // password="", submit-button shows "Send sign-in link" → SPA + // fires magic-link/send with the concatenated identifier and + // login never completes. + // + // `networkidle` waits for the network to have no more than 0 + // requests in flight for 500 ms. By that point providers + // + status have landed and `booting = false` has already + // stabilised → the auto-focus effect fired ONCE (harmlessly, + // before we touch the form), never again during our fills. + await page.waitForLoadState('networkidle'); + await page.getByTestId('login-username-input').fill(admin.username); + await page.getByTestId('login-password-input').fill(admin.password); + await page.getByTestId('login-submit-btn').click(); // Post-login the SPA's `goto(redirectTarget)` sends the user - // to `/files` (default) or a `?redirect=` target. Match the - // default with a glob — the same shape `uiLogin` uses in - // `spa/coverage-helpers.ts` and that Playwright handles well - // under SvelteKit's client-side navigation. The 15s ceiling - // covers the OPAQUE-post-migration path: WASM load + KE1 + - // KE3 + Argon2id. - await page.waitForURL('**/files**', { timeout: 15_000 }); + // to `/files` (default) or a `?redirect=` target — OR to + // `/profile?forcePasswordChange=1` when the backend has stamped + // `force_password_change_at_next_login=true` on this account + // (usually because a prior admin-reset test flipped it). Match + // any post-login destination that ISN'T `/login` itself. The + // 15s ceiling covers the OPAQUE-post-migration path: WASM load + // + KE1 + KE3 + Argon2id. + await page.waitForURL((url) => !url.pathname.startsWith('/login'), { + timeout: 15_000, + waitUntil: 'commit' + }); } /**