diff --git a/frontend/src/lib/api/endpoints/auth.ts b/frontend/src/lib/api/endpoints/auth.ts index a0155f4a..0ce5b276 100644 --- a/frontend/src/lib/api/endpoints/auth.ts +++ b/frontend/src/lib/api/endpoints/auth.ts @@ -456,6 +456,7 @@ export async function startOidcLink(): Promise { headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, body: '{}' }); +<<<<<<< HEAD if (!res.ok) { const { errorType, message } = await parseErrorBody(res); throw new ApiError(res.status, res.statusText, '/api/auth/oidc/link/start', errorType, message); @@ -503,6 +504,23 @@ export async function logout(): Promise { headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, body: '{}' }); + + // Wipe DPoP browser state so the next login mints a fresh + // keypair — no correlation across the logout boundary is + // desirable (a new session is a new identity from the + // per-request-signature standpoint). Runs UNCONDITIONALLY of + // the logout HTTP status: even if the server call failed, + // the user's intent was to log out, and leaving a stale + // keypair around would confuse the next login's bind step. + try { + const { clearKeypair } = await import('$lib/auth/dpop'); + const { clearNonce } = await import('$lib/auth/dpop-proof'); + await clearKeypair(); + clearNonce(); + } catch (err) { + console.debug('dpop: cleanup failed during logout', err); + } + if (!res.ok) return {}; try { const body = (await res.json()) as { post_logout_url?: unknown }; diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 5ba0d7c7..a8407204 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -1483,7 +1483,15 @@ impl AuthApplicationService { // new one happen in ONE transaction (`rotate_session`) — this path // used to pay two BEGIN/COMMIT pairs per refresh, and DAV clients // rotate constantly (benches/ROUND12.md §4). - let new_session = Session::new( + // + // The DPoP binding travels with the family: if the parent session + // was bound to a browser-held keypair, the refreshed session MUST + // be bound to the same one (see `docs/plan/dpop.md` Gate 7). Same + // browser → same key → same jkt. Skipping this would let a + // refresh silently downgrade the session to unbound, and every + // subsequent request would fail DPoP verification once required + // mode enforces per-session binding. + let mut new_session = Session::new( user.id(), new_refresh_token.clone(), None, @@ -1491,6 +1499,9 @@ impl AuthApplicationService { self.token_service.refresh_token_expiry_days(), session.family_id(), ); + if let Some(jkt) = session.dpop_jkt() { + new_session = new_session.with_dpop_jkt(jkt.to_string()); + } self.session_storage .rotate_session(session.id(), new_session)