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:
@@ -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<User> 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<User>` runs. Other UserDto emitters leave
|
||||
// this at `false` (they lack session context).
|
||||
is_dpop_bound: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Arc<AppState>>,
|
||||
CurrentUserId(user_id): CurrentUserId,
|
||||
auth_user: AuthUser,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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)))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user