fix(oidc): make SSO callback idempotent + evict stale legacy service worker (#510)

OIDC SSO login intermittently ended on a 403 "Invalid or expired OIDC state
— possible CSRF attack" even though the login had already succeeded
server-side.

Root cause: the (now-removed) legacy vanilla-JS frontend registered a
`/sw.js` service worker that, with navigation preload enabled, double-fetched
the top-level navigation to `/api/auth/oidc/callback`. The OIDC `state` is
single-use, so the first callback consumed it and logged the user in while
the duplicate (~0.4s later) found the state gone and returned the 403 the
browser rendered.

Backend — idempotent callback: after a successful web login, remember
`state -> exchange_code` in a short-lived (120s) cache. A duplicate callback
whose state was already consumed now replays that same redirect instead of
403-ing, returning the cached result directly without re-running the IdP code
exchange (the authorization `code` is single-use too). Keyed by the
unguessable 32-byte state, so it adds no new attack surface and fixes the 403
for everyone — including browsers still running a stale legacy service worker.

Frontend — evict the stale worker: the current SvelteKit app registers no
service worker, so fresh clients can't double-fire. But a browser that
previously loaded the legacy frontend still has `/sw.js` registered and
controlling pages (and `/sw.js` now 404s, so vendor self-cleanup is
inconsistent). killLegacyServiceWorker() runs first in the root layout's
onMount: it surgically unregisters only `/sw.js` workers, drops only the
legacy `oxicloud-cache-*` caches, and reloads once (guarded).

Fixes #510.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Meier
2026-06-21 11:31:59 -05:00
parent 778d551090
commit f42756aa29
3 changed files with 64 additions and 7 deletions
@@ -137,6 +137,7 @@ pub struct AuthApplicationService {
/// Pending one-time token codes for secure token delivery after OIDC callback.
/// Auto-expires after 60 seconds via moka TTL; max 10 000 entries for DoS protection.
pending_oidc_tokens: Cache<String, PendingOidcToken>,
completed_oidc_logins: Cache<String, String>,
/// Magic-link token repository — populated when the magic-link feature
/// is enabled (PR 8+). `None` means redemption endpoints return 503.
magic_link_repo: Option<Arc<dyn MagicLinkTokenRepository>>,
@@ -181,6 +182,10 @@ impl AuthApplicationService {
.max_capacity(10_000)
.time_to_live(Duration::from_secs(60))
.build(),
completed_oidc_logins: Cache::builder()
.max_capacity(10_000)
.time_to_live(Duration::from_secs(120))
.build(),
magic_link_repo: None,
user_flags_cache: Cache::builder()
.max_capacity(10_000)
@@ -2020,13 +2025,31 @@ impl AuthApplicationService {
) -> Result<OidcCallbackResult, DomainError> {
// 0. Validate CSRF state and retrieve PKCE verifier + nonce + optional NC token
// (entry is auto-expired by moka TTL — remove returns None if expired)
let flow = self.pending_oidc_flows.remove(state).ok_or_else(|| {
tracing::warn!("OIDC callback with invalid/expired state token");
DomainError::new(
ErrorKind::AccessDenied, "OIDC",
"Invalid or expired OIDC state — possible CSRF attack. Please try logging in again.",
)
})?;
let flow = match self.pending_oidc_flows.remove(state) {
Some(flow) => flow,
None => {
if let Some(exchange_code) = self.completed_oidc_logins.get(state) {
tracing::info!(
target: "audit",
event = "oidc.callback_replayed",
reason = "duplicate_callback",
"👮🏻‍♂️ Replayed a recently-completed OIDC login for a duplicate callback (consumed state)",
);
return Ok(OidcCallbackResult::WebLogin { exchange_code });
}
tracing::warn!(
target: "audit",
event = "oidc.callback_rejected",
reason = "invalid_or_expired_state",
"👮🏻‍♂️ OIDC callback with invalid/expired state token",
);
return Err(DomainError::new(
ErrorKind::AccessDenied,
"OIDC",
"Invalid or expired OIDC state — possible CSRF attack. Please try logging in again.",
));
}
};
let (pkce_verifier, nonce, nc_flow_token) =
(flow.pkce_verifier, flow.nonce, flow.nc_flow_token);
@@ -2324,6 +2347,9 @@ impl AuthApplicationService {
self.pending_oidc_tokens
.insert(exchange_code.clone(), PendingOidcToken { auth_response });
self.completed_oidc_logins
.insert(state.to_string(), exchange_code.clone());
tracing::info!("OIDC login successful, one-time exchange code generated");
Ok(OidcCallbackResult::WebLogin { exchange_code })