From 69c57e1e24ed261a936860b34f6ad4379a2d4beb Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 9 Aug 2026 12:04:45 +0200 Subject: [PATCH] feat(dpop): client now aware if session if bound this prevent client to try binding and creating - unnecessary call - unnecessary warning in server log --- frontend/src/lib/api/types.ts | 14 ++++++++++++++ frontend/src/lib/stores/session.svelte.ts | 14 ++++++++------ src/application/dtos/user_dto.rs | 20 ++++++++++++++++++++ src/interfaces/api/handlers/auth_handler.rs | 15 +++++++++++++-- 4 files changed, 55 insertions(+), 8 deletions(-) diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 37f8ca14..eab34726 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -256,6 +256,20 @@ export interface User { * card). */ has_password?: boolean; + /** + * TRUE when the caller's current session is DPoP-bound (row's + * `dpop_jkt IS NOT NULL`). Populated only by `/api/auth/me`; other + * User-emitting endpoints leave it unset. + * + * The session store reads this to skip a redundant + * `POST /api/auth/dpop/bind` call — the endpoint returns 409 + * `already_bound` on repeated attempts (anti-downgrade invariant) + * and each rejection logs at audit INFO, so a naive "bind on + * every load" pattern was cluttering the audit stream. We only + * fire bind now when there's actual work to do (fresh OIDC / + * magic-link session that landed unbound). + */ + is_dpop_bound?: boolean; } /** Fields rendered by the paginated admin table. Full account details remain diff --git a/frontend/src/lib/stores/session.svelte.ts b/frontend/src/lib/stores/session.svelte.ts index 86148641..465b28eb 100644 --- a/frontend/src/lib/stores/session.svelte.ts +++ b/frontend/src/lib/stores/session.svelte.ts @@ -50,12 +50,14 @@ class SessionStore { // Post-redirect DPoP bind — catches OIDC / magic-link // flows whose server-side callback creates the session // UNBOUND (no way for the redirect to carry the JKT in - // the callback body). One-shot per SPA lifetime because - // `this.loaded` guard makes `load()` a singleton; - // server returns 409 if the session is already bound - // (harmless — result is swallowed). Fire-and-forget so - // a slow IndexedDB open doesn't stall the app boot. - void bindDpopIfPossible(); + // the callback body). Gate on `is_dpop_bound` so we + // don't call the endpoint on every SPA load: password + // login already binds at session-mint time, so `/me` + // reports `true` on the very first request and skip + // avoids the 409 `already_bound` reject that would + // otherwise clutter the audit stream. Fire-and-forget + // so a slow IndexedDB open doesn't stall app boot. + if (me.is_dpop_bound === false) void bindDpopIfPossible(); } else this.user = null; } catch { this.user = null; diff --git a/src/application/dtos/user_dto.rs b/src/application/dtos/user_dto.rs index e2580102..e90ac9a4 100644 --- a/src/application/dtos/user_dto.rs +++ b/src/application/dtos/user_dto.rs @@ -137,6 +137,21 @@ pub struct UserDto { /// need to surface per-user credential state. #[serde(default)] pub has_password: bool, + /// TRUE when the caller's current session carries a DPoP JWK + /// thumbprint (`session.dpop_jkt IS NOT NULL`). Sourced from the + /// caller's JWT `cnf.jkt` claim — `is_some()` means the session + /// was bound at token-mint time. + /// + /// Populated only by the `/api/auth/me` handler; other UserDto + /// emitters leave it `false`. The SPA reads this on `session.load()` + /// to skip a redundant `POST /api/auth/dpop/bind` call when the + /// session is already bound (which would 409 and log noisily under + /// the audit stream — see the `already_bound` reject). Only the + /// OIDC / magic-link redirect flows land here as `false` on first + /// visit; password login binds at session-mint time so the very + /// first `/me` after login already reports `true`. + #[serde(default)] + pub is_dpop_bound: bool, } /// Compact row returned by the paginated admin user table. @@ -264,6 +279,11 @@ impl From for UserDto { // not a general user attribute. force_password_change: false, has_password, + // Populated only by `/api/auth/me` — the handler overlays + // the caller's session's actual DPoP binding state after + // this `From` runs. Other UserDto emitters leave + // this at `false` (they lack session context). + is_dpop_bound: false, } } } diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index 6b43fd2e..9750417e 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -19,7 +19,7 @@ use crate::application::services::auth_application_service::{OidcCallbackResult, use crate::common::di::AppState; use crate::interfaces::api::cookie_auth; use crate::interfaces::errors::AppError; -use crate::interfaces::middleware::auth::CurrentUserId; +use crate::interfaces::middleware::auth::{AuthUser, CurrentUserId}; use crate::interfaces::middleware::trusted_proxy::client_ip_from_parts; use serde::Deserialize; @@ -620,8 +620,9 @@ pub async fn refresh_token( )] pub async fn get_current_user( State(state): State>, - CurrentUserId(user_id): CurrentUserId, + auth_user: AuthUser, ) -> Result { + let user_id = auth_user.id; let auth_service = state .auth_service .as_ref() @@ -657,6 +658,16 @@ pub async fn get_current_user( user.force_password_change = flags.force_password_change; } + // Session-binding state — read from the JWT `cnf.jkt` claim + // (surfaced by the auth middleware into `CurrentUser.dpop_jkt`). + // Present ⇒ the session that minted this JWT was bound; absent ⇒ + // the session is unbound and the SPA should call `/dpop/bind` + // to attach the browser's keypair (OIDC / magic-link redirect + // flow). Skips an otherwise-redundant `POST /dpop/bind` on every + // page load which would return 409 `already_bound` and litter + // the audit stream. + user.is_dpop_bound = auth_user.dpop_jkt.is_some(); + Ok((StatusCode::OK, Json(user))) }