feat(dpop): client now aware if session if bound

this prevent client to try binding and creating
- unnecessary call
- unnecessary warning in server log
This commit is contained in:
Edouard Vanbelle
2026-08-09 12:04:45 +02:00
parent df9a6babbb
commit 69c57e1e24
4 changed files with 55 additions and 8 deletions
+14
View File
@@ -256,6 +256,20 @@ export interface User {
* card). * card).
*/ */
has_password?: boolean; 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 /** Fields rendered by the paginated admin table. Full account details remain
+8 -6
View File
@@ -50,12 +50,14 @@ class SessionStore {
// Post-redirect DPoP bind — catches OIDC / magic-link // Post-redirect DPoP bind — catches OIDC / magic-link
// flows whose server-side callback creates the session // flows whose server-side callback creates the session
// UNBOUND (no way for the redirect to carry the JKT in // UNBOUND (no way for the redirect to carry the JKT in
// the callback body). One-shot per SPA lifetime because // the callback body). Gate on `is_dpop_bound` so we
// `this.loaded` guard makes `load()` a singleton; // don't call the endpoint on every SPA load: password
// server returns 409 if the session is already bound // login already binds at session-mint time, so `/me`
// (harmless — result is swallowed). Fire-and-forget so // reports `true` on the very first request and skip
// a slow IndexedDB open doesn't stall the app boot. // avoids the 409 `already_bound` reject that would
void bindDpopIfPossible(); // 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; } else this.user = null;
} catch { } catch {
this.user = null; this.user = null;
+20
View File
@@ -137,6 +137,21 @@ pub struct UserDto {
/// need to surface per-user credential state. /// need to surface per-user credential state.
#[serde(default)] #[serde(default)]
pub has_password: bool, 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. /// Compact row returned by the paginated admin user table.
@@ -264,6 +279,11 @@ impl From<User> for UserDto {
// not a general user attribute. // not a general user attribute.
force_password_change: false, force_password_change: false,
has_password, has_password,
// Populated only by `/api/auth/me` — the handler overlays
// the caller's session's actual DPoP binding state after
// this `From<User>` runs. Other UserDto emitters leave
// this at `false` (they lack session context).
is_dpop_bound: false,
} }
} }
} }
+13 -2
View File
@@ -19,7 +19,7 @@ use crate::application::services::auth_application_service::{OidcCallbackResult,
use crate::common::di::AppState; use crate::common::di::AppState;
use crate::interfaces::api::cookie_auth; use crate::interfaces::api::cookie_auth;
use crate::interfaces::errors::AppError; 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 crate::interfaces::middleware::trusted_proxy::client_ip_from_parts;
use serde::Deserialize; use serde::Deserialize;
@@ -620,8 +620,9 @@ pub async fn refresh_token(
)] )]
pub async fn get_current_user( pub async fn get_current_user(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
CurrentUserId(user_id): CurrentUserId, auth_user: AuthUser,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
let user_id = auth_user.id;
let auth_service = state let auth_service = state
.auth_service .auth_service
.as_ref() .as_ref()
@@ -657,6 +658,16 @@ pub async fn get_current_user(
user.force_password_change = flags.force_password_change; 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))) Ok((StatusCode::OK, Json(user)))
} }