fix(dpop): fix issue with sveltekit and playwright
await page.waitForLoadState('networkidle') is the key before starting
This commit is contained in:
@@ -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<User | null> {
|
||||
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<Response> => {
|
||||
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;
|
||||
|
||||
@@ -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.
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -142,17 +142,38 @@ export async function apiLogin(page: Page, admin = TEST_ADMIN): Promise<void> {
|
||||
}
|
||||
|
||||
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'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user