From 8b79e26329f036f1aff7141687abf30f60ef4cec Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sat, 8 Aug 2026 14:15:26 +0200 Subject: [PATCH] feat(DPoP): bing ceremony on login --- frontend/src/lib/api/endpoints/auth.ts | 66 ++++++++- frontend/src/lib/api/endpoints/opaque.test.ts | 6 +- frontend/src/lib/api/endpoints/opaque.ts | 15 +- src/application/dtos/user_dto.rs | 7 + src/application/ports/auth_ports.rs | 11 ++ .../services/auth_application_service.rs | 140 +++++++++++++++++- src/domain/repositories/session_repository.rs | 31 ++++ .../repositories/pg/session_pg_repository.rs | 52 +++++++ src/interfaces/api/handlers/auth_handler.rs | 70 +++++++++ .../api/handlers/opaque_auth_handler.rs | 7 +- 10 files changed, 394 insertions(+), 11 deletions(-) diff --git a/frontend/src/lib/api/endpoints/auth.ts b/frontend/src/lib/api/endpoints/auth.ts index 6c0ac025..a0155f4a 100644 --- a/frontend/src/lib/api/endpoints/auth.ts +++ b/frontend/src/lib/api/endpoints/auth.ts @@ -63,7 +63,65 @@ export async function tryRefresh(): Promise { } } +/** + * Compute a DPoP JWK thumbprint for the current browser keypair, if + * WebCrypto + IndexedDB are available. Returned to callers as an + * optional string; a `null` return means the browser can't support + * DPoP, and login proceeds unbound (see `docs/plan/dpop.md` — fail- + * open per-session, immutable so bound sessions can't be downgraded). + * + * Any failure is swallowed to `null` — a DPoP hiccup MUST NOT block + * authentication. The unbound session is still functional, just not + * DPoP-protected against info-stealer replay. + */ +async function tryBindingThumbprint(): Promise { + try { + const { ensureKeypair, computeJkt } = await import('$lib/auth/dpop'); + const kp = await ensureKeypair(); + return await computeJkt(kp.publicKey); + } catch (err) { + console.debug('dpop: keypair unavailable, logging in unbound', err); + return null; + } +} + +/** + * Post-redirect DPoP bind — for OIDC callback and magic-link + * redemption pages, where the session was created before the SPA had + * a chance to send its thumbprint in the login body. Call once, + * fire-and-forget style: any failure (409 already bound, 400 + * malformed, network error) is swallowed to `false` — the session + * either was already bound (no harm) or can't be bound now (fail- + * open per plan). + * + * Returns `true` when the bind succeeded, `false` otherwise. Callers + * typically ignore the return value; they might log it in dev. + */ +export async function bindDpopIfPossible(): Promise { + const jkt = await tryBindingThumbprint(); + if (!jkt) return false; + try { + const res = await apiFetch('/api/auth/dpop/bind', { + method: 'POST', + credentials: 'same-origin', + headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, + body: JSON.stringify({ dpop_jkt: jkt }) + }); + return res.ok; + } catch (err) { + console.debug('dpop: bind endpoint call failed', err); + return false; + } +} + export async function login(emailOrUsername: string, password: string): Promise { + // Compute DPoP JKT ONCE per login attempt so both branches + // (OPAQUE and legacy) send the same value. Null = browser can't + // support DPoP (missing SubtleCrypto / IndexedDB / secure context) + // or a transient failure; the server accepts absent → session + // created unbound. + const dpopJkt = await tryBindingThumbprint(); + // ── OPAQUE lookup (Phase 3) ──────────────────────────────────────── // Ask the server whether this identifier already has an OPAQUE // envelope on file. If yes → use OPAQUE login (KE1/KE3). If no → @@ -98,7 +156,7 @@ export async function login(emailOrUsername: string, password: string): Promise< // per-envelope KSF storage (`ksf === null`), which preserves // the pre-migration behaviour. const ksf = lookup.ksf ?? (await opaqueKsfForClient()); - const auth = await opaqueLogin(emailOrUsername, password, ksf); + const auth = await opaqueLogin(emailOrUsername, password, ksf, dpopJkt); // Phase C: silent KSF rotation. If this envelope's KSF drifted // from what the server currently publishes (operator retuned @@ -131,7 +189,11 @@ export async function login(emailOrUsername: string, password: string): Promise< method: 'POST', credentials: 'same-origin', headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, - body: JSON.stringify({ username: emailOrUsername, password }) + body: JSON.stringify({ + username: emailOrUsername, + password, + ...(dpopJkt ? { dpop_jkt: dpopJkt } : {}) + }) }); if (!res.ok) { // Surface the backend `error_type` so the login page can offer diff --git a/frontend/src/lib/api/endpoints/opaque.test.ts b/frontend/src/lib/api/endpoints/opaque.test.ts index ea31ecad..2c3b52cd 100644 --- a/frontend/src/lib/api/endpoints/opaque.test.ts +++ b/frontend/src/lib/api/endpoints/opaque.test.ts @@ -167,7 +167,7 @@ describe('opaqueLogin', () => { expires_in: 3600 }) ); - const auth = await opaqueLogin('alice@example.com', 'pw', KSF); + const auth = await opaqueLogin('alice@example.com', 'pw', KSF, null); expect(auth.access_token).toBe('at'); const [ke1Url, ke1Init] = f.mock.calls[0]; @@ -192,7 +192,7 @@ describe('opaqueLogin', () => { // requires both paths look identical to the caller. fin.mockReturnValueOnce(undefined); f.mockResolvedValueOnce(okJson({ exchangeId: 'XID', loginResponse: 'RESP-L' })); - await expect(opaqueLogin('a@x.test', 'wrong', KSF)).rejects.toMatchObject({ + await expect(opaqueLogin('a@x.test', 'wrong', KSF, null)).rejects.toMatchObject({ status: 401, errorType: 'InvalidCredentials' }); @@ -201,7 +201,7 @@ describe('opaqueLogin', () => { it('bubbles up the server error_type on KE1 failure', async () => { f.mockResolvedValueOnce(errJson(429, { error_type: 'RateLimited', message: 'slow down' })); - const err = await opaqueLogin('a@x.test', 'pw', KSF).then( + const err = await opaqueLogin('a@x.test', 'pw', KSF, null).then( () => null, (e) => e ); diff --git a/frontend/src/lib/api/endpoints/opaque.ts b/frontend/src/lib/api/endpoints/opaque.ts index e687c1e2..031e253d 100644 --- a/frontend/src/lib/api/endpoints/opaque.ts +++ b/frontend/src/lib/api/endpoints/opaque.ts @@ -349,7 +349,14 @@ export async function opaqueRegister( export async function opaqueLogin( userIdentifier: string, password: string, - ksf: OpaqueKsfConfig + ksf: OpaqueKsfConfig, + /** + * DPoP JWK thumbprint (RFC 7638) to bind the resulting session to + * this browser's keypair. `null` → session created unbound (fail- + * open per `docs/plan/dpop.md`; the caller in `endpoints/auth.ts` + * already tried to compute the thumbprint and swallowed failures). + */ + dpopJkt: string | null ): Promise { const client = await opaqueWasm(); @@ -402,7 +409,11 @@ export async function opaqueLogin( method: 'POST', credentials: 'same-origin', headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, - body: JSON.stringify({ exchangeId, finishLoginRequest }) + body: JSON.stringify({ + exchangeId, + finishLoginRequest, + ...(dpopJkt ? { dpopJkt } : {}) + }) }); if (!ke3Res.ok) { const { errorType, message } = await parseErrorBody(ke3Res); diff --git a/src/application/dtos/user_dto.rs b/src/application/dtos/user_dto.rs index cb975d7d..4943e532 100644 --- a/src/application/dtos/user_dto.rs +++ b/src/application/dtos/user_dto.rs @@ -279,6 +279,13 @@ pub struct LoginDto { /// typed in the "Username or email" field as-is. pub username: String, pub password: String, + /// DPoP JWK thumbprint the client generated at page load. When + /// present, binds the new session to a browser-held keypair so + /// stealing the cookie without the private key is useless (RFC + /// 9449). Absent → session is created unbound (fail-open per the + /// `docs/plan/dpop.md` threat model). Malformed → 400. + #[serde(default, rename = "dpop_jkt", alias = "dpopJkt")] + pub dpop_jkt: Option, } #[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] diff --git a/src/application/ports/auth_ports.rs b/src/application/ports/auth_ports.rs index 4a1c6399..a32aca41 100644 --- a/src/application/ports/auth_ports.rs +++ b/src/application/ports/auth_ports.rs @@ -473,6 +473,17 @@ pub trait SessionStoragePort: Send + Sync + 'static { issuer: &str, subject: &str, ) -> Result, DomainError>; + + /// One-shot bind a DPoP JWK thumbprint to a session that was created + /// without one (post-redirect flow — OIDC callback, magic-link + /// redemption). Fails with `AlreadyExists` if the session already + /// carries a thumbprint (anti-downgrade invariant, see + /// `docs/plan/dpop.md`). + async fn bind_dpop_jkt( + &self, + session_id: Uuid, + dpop_jkt: &str, + ) -> Result<(), DomainError>; } // ============================================================================ diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 774cba4a..47638a50 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -28,6 +28,65 @@ use std::sync::RwLock; use std::time::Duration; use uuid::Uuid; +/// Validate a client-supplied DPoP JWK thumbprint. RFC 7638 §3 produces +/// a base64url-encoded SHA-256 (32 bytes → 43 base64url chars, no +/// padding). We accept exactly that shape; anything else is a client +/// bug or forgery attempt and gets rejected at the login boundary. +/// +/// Returned string is the exact input on success — we don't +/// canonicalise the thumbprint further (it IS the canonical form). +fn validate_dpop_jkt(raw: &str) -> Result { + if raw.len() != 43 { + return Err("DPoP thumbprint must be 43 characters (base64url SHA-256)"); + } + if !raw + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + { + return Err("DPoP thumbprint contains non-base64url characters"); + } + Ok(raw.to_string()) +} + +#[cfg(test)] +mod dpop_jkt_tests { + use super::validate_dpop_jkt; + + #[test] + fn accepts_well_formed_thumbprint() { + // 43 base64url chars — a real SHA-256 output shape + let jkt = "AbCdEfGhIjKlMnOpQrStUvWxYz0123456789-_ABCDE"; + assert_eq!(validate_dpop_jkt(jkt).unwrap(), jkt); + } + + #[test] + fn rejects_wrong_length() { + assert!(validate_dpop_jkt("").is_err()); + assert!(validate_dpop_jkt("too-short").is_err()); + assert!( + validate_dpop_jkt(&"a".repeat(44)).is_err(), + "44 chars must be rejected" + ); + } + + #[test] + fn rejects_padding() { + // 43-char string ending in `=` is still 43 chars but invalid + // base64url (padding never appears in URL_SAFE_NO_PAD). + let with_pad = format!("{}{}", "a".repeat(42), "="); + assert!(validate_dpop_jkt(&with_pad).is_err()); + } + + #[test] + fn rejects_standard_base64_alphabet() { + // `+` and `/` are standard base64 — url-safe uses `-` and `_` + let with_plus = format!("{}+", "a".repeat(42)); + let with_slash = format!("{}/", "a".repeat(42)); + assert!(validate_dpop_jkt(&with_plus).is_err()); + assert!(validate_dpop_jkt(&with_slash).is_err()); + } +} + /// Result of a successful OIDC callback. The handler layer inspects this to /// decide whether to redirect to the regular frontend or complete a Nextcloud /// Login Flow v2 session. @@ -947,7 +1006,8 @@ impl AuthApplicationService { // handshake (Phase 1, `login/ke3`). Both paths converge here // so lifecycle + token + session-family semantics stay in // one place. - self.mint_session_for_authenticated_user(user).await + self.mint_session_for_authenticated_user(user, dto.dpop_jkt) + .await } /// Emit a fresh session for a user who has ALREADY been @@ -973,6 +1033,7 @@ impl AuthApplicationService { pub async fn mint_session_for_authenticated_user( &self, mut user: crate::domain::entities::user::User, + dpop_jkt: Option, ) -> Result { // Lifecycle: dispatch login BEFORE register_login() so hooks // observing `last_login_at().is_none()` see "first ever login" @@ -995,8 +1056,11 @@ impl AuthApplicationService { let refresh_token = self.token_service.generate_refresh_token(); - // Save session — new login starts a new token family - let session = Session::new( + // Save session — new login starts a new token family. DPoP + // binding is set at INSERT time and immutable thereafter (see + // `docs/plan/dpop.md` — a mutable bind would let an attacker + // downgrade a bound session by re-binding to their own key). + let mut session = Session::new( user.id(), refresh_token.clone(), None, // IP (can be added from the HTTP layer) @@ -1004,6 +1068,23 @@ impl AuthApplicationService { self.token_service.refresh_token_expiry_days(), Uuid::new_v4(), ); + if let Some(jkt) = dpop_jkt { + let validated = validate_dpop_jkt(&jkt).map_err(|e| { + tracing::info!( + target: "audit", + event = "auth.dpop_bind_rejected", + reason = "malformed_thumbprint", + user_id = %user.id(), + "🔐 DPoP bind rejected: {}", e, + ); + DomainError::new( + ErrorKind::InvalidInput, + "Auth", + "dpop_jkt must be a 43-character base64url SHA-256 thumbprint (RFC 7638)", + ) + })?; + session = session.with_dpop_jkt(validated); + } self.session_storage.create_session(session).await?; @@ -2153,6 +2234,59 @@ impl AuthApplicationService { } } + /// Bind a DPoP JWK thumbprint to an EXISTING session — the + /// post-redirect path for OIDC and magic-link, whose redemptions + /// are GET requests and can't thread the thumbprint through the + /// login body. The SPA calls this once, immediately after the + /// redirect lands, with the thumbprint it generated at page load. + /// + /// Emits `auth.dpop_bind_rejected` on validation failure or when + /// the caller tries to re-bind an already-bound session (anti- + /// downgrade guard). Emits `auth.dpop_bound` on the accept path + /// so operators can correlate binding events with sessions. + pub async fn bind_dpop_jkt_to_session( + &self, + session_id: Uuid, + dpop_jkt: &str, + ) -> Result<(), DomainError> { + let validated = validate_dpop_jkt(dpop_jkt).map_err(|e| { + tracing::info!( + target: "audit", + event = "auth.dpop_bind_rejected", + reason = "malformed_thumbprint", + session_id = %session_id, + "🔐 DPoP bind rejected: {}", e, + ); + DomainError::new( + ErrorKind::InvalidInput, + "Auth", + "dpop_jkt must be a 43-character base64url SHA-256 thumbprint (RFC 7638)", + ) + })?; + match self.session_storage.bind_dpop_jkt(session_id, &validated).await { + Ok(()) => { + tracing::info!( + target: "audit", + event = "auth.dpop_bound", + session_id = %session_id, + "🔐 DPoP thumbprint bound to session", + ); + Ok(()) + } + Err(e) if e.kind == ErrorKind::AlreadyExists => { + tracing::info!( + target: "audit", + event = "auth.dpop_bind_rejected", + reason = "already_bound", + session_id = %session_id, + "🔐 DPoP bind rejected: session already bound", + ); + Err(e) + } + Err(e) => Err(e), + } + } + pub async fn get_user_flags(&self, user_id: Uuid) -> Result { // Single-flight: concurrent misses for the same user coalesce // into ONE storage lookup; errors are never cached (same herd diff --git a/src/domain/repositories/session_repository.rs b/src/domain/repositories/session_repository.rs index a25f7c69..f6ae88ef 100644 --- a/src/domain/repositories/session_repository.rs +++ b/src/domain/repositories/session_repository.rs @@ -12,6 +12,13 @@ pub enum SessionRepositoryError { #[error("Timeout error: {0}")] Timeout(String), + + /// Attempted to bind a DPoP thumbprint to a session that already + /// carries one. Immutable-per-session invariant (see + /// `docs/plan/dpop.md` — mutable bind would let an attacker + /// downgrade a bound session by binding to their own key). + #[error("Session already has a DPoP thumbprint")] + DpopAlreadyBound, } pub type SessionRepositoryResult = Result; @@ -25,6 +32,11 @@ impl From for DomainError { DomainError::internal_error("Database", msg) } SessionRepositoryError::Timeout(msg) => DomainError::timeout("Database", msg), + SessionRepositoryError::DpopAlreadyBound => DomainError::new( + crate::common::errors::ErrorKind::AlreadyExists, + "Session", + "This session already has a DPoP thumbprint and cannot be re-bound", + ), } } } @@ -95,4 +107,23 @@ pub trait SessionRepository: Send + Sync + 'static { /// Deletes expired sessions async fn delete_expired_sessions(&self) -> SessionRepositoryResult; + + /// One-shot bind a DPoP JWK thumbprint (RFC 7638) to a session that + /// was created without one. Used by the post-redirect bind endpoint + /// (`POST /api/auth/dpop/bind`) for the OIDC and magic-link flows, + /// where the redemption is a GET and can't carry the thumbprint in + /// its request body. + /// + /// Enforces the immutability invariant at the SQL level with a + /// `WHERE dpop_jkt IS NULL` guard: if the row already carries a + /// thumbprint the UPDATE affects zero rows and we return + /// [`SessionRepositoryError::DpopAlreadyBound`]. That's the anti- + /// downgrade guard from `docs/plan/dpop.md` — an attacker who has + /// stolen the cookie of a bound session cannot re-bind to their + /// own key. + async fn bind_dpop_jkt( + &self, + session_id: Uuid, + dpop_jkt: &str, + ) -> SessionRepositoryResult<()>; } diff --git a/src/infrastructure/repositories/pg/session_pg_repository.rs b/src/infrastructure/repositories/pg/session_pg_repository.rs index edf3dff2..04d936a3 100644 --- a/src/infrastructure/repositories/pg/session_pg_repository.rs +++ b/src/infrastructure/repositories/pg/session_pg_repository.rs @@ -468,6 +468,48 @@ impl SessionRepository for SessionPgRepository { Ok(result.rows_affected()) } + + async fn bind_dpop_jkt( + &self, + session_id: Uuid, + dpop_jkt: &str, + ) -> SessionRepositoryResult<()> { + // `WHERE dpop_jkt IS NULL` enforces the immutability invariant + // at the SQL level — a bound session's UPDATE affects 0 rows + // and we surface `DpopAlreadyBound`. Also guards against a + // stolen cookie replaying the bind endpoint with the + // attacker's own thumbprint on an already-bound session. + let result = sqlx::query( + r#" + UPDATE auth.sessions + SET dpop_jkt = $2 + WHERE id = $1 AND dpop_jkt IS NULL + "#, + ) + .bind(session_id) + .bind(dpop_jkt) + .execute(&*self.pool) + .await + .map_err(Self::map_sqlx_error)?; + + if result.rows_affected() == 0 { + // Distinguish "session gone" from "already bound" — the + // caller (bind endpoint) returns different HTTP shapes. + // A tiny extra SELECT here is worth the disambiguation + // because both cases are rare. + let row = sqlx::query("SELECT dpop_jkt FROM auth.sessions WHERE id = $1") + .bind(session_id) + .fetch_optional(&*self.pool) + .await + .map_err(Self::map_sqlx_error)?; + return match row { + None => Err(SessionRepositoryError::NotFound(session_id.to_string())), + Some(_) => Err(SessionRepositoryError::DpopAlreadyBound), + }; + } + + Ok(()) + } } // Implementation of the storage port for the application layer @@ -605,4 +647,14 @@ impl SessionStoragePort for SessionPgRepository { .await .map_err(DomainError::from) } + + async fn bind_dpop_jkt( + &self, + session_id: Uuid, + dpop_jkt: &str, + ) -> Result<(), DomainError> { + SessionRepository::bind_dpop_jkt(self, session_id, dpop_jkt) + .await + .map_err(DomainError::from) + } } diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index 35f57799..3861db43 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -55,6 +55,7 @@ pub fn auth_protected_routes() -> Router> { // docs/plan/oidc-account-linking.md. .route("/oidc/link/start", post(oidc_link_start)) .route("/oidc/unlink", post(oidc_unlink)) + .route("/dpop/bind", post(dpop_bind)) } /// Rate-limited auth routes, split out so main.rs can apply per-endpoint @@ -1007,6 +1008,75 @@ pub async fn logout( Ok(response) } +/// Post-redirect DPoP bind DTO — only field is the JWK thumbprint. +#[derive(Debug, serde::Deserialize, ToSchema)] +pub struct DpopBindDto { + /// Base64url SHA-256 of the canonical public-key JWK (RFC 7638) — + /// exactly 43 characters, `[A-Za-z0-9_-]`. + #[serde(rename = "dpop_jkt", alias = "dpopJkt")] + pub dpop_jkt: String, +} + +/// One-shot bind a DPoP JWK thumbprint to the caller's current session. +/// +/// Purpose: post-redirect flows (OIDC callback, magic-link redemption) +/// create the session before the SPA has a chance to send its DPoP +/// keypair thumbprint. The SPA calls this endpoint immediately after +/// the redirect lands, so the session graduates from unbound to bound +/// before the first authenticated `/api/*` request. +/// +/// Contract: +/// * 200 on success — session now carries the thumbprint. +/// * 400 if the thumbprint is malformed (wrong length / non-base64url). +/// * 409 if the session already carries a thumbprint (anti-downgrade +/// invariant per `docs/plan/dpop.md` — a bound session cannot be +/// re-bound to a different key). +/// * 401 if no session (auth middleware layer emits this). +#[utoipa::path( + post, + path = "/api/auth/dpop/bind", + request_body = DpopBindDto, + responses( + (status = 200, description = "Thumbprint bound"), + (status = 400, description = "Malformed thumbprint"), + (status = 401, description = "Not authenticated"), + (status = 409, description = "Session already bound"), + ), + security(("bearerAuth" = [])), + tag = "auth" +)] +pub async fn dpop_bind( + State(state): State>, + CurrentUserId(_user_id): CurrentUserId, + headers: HeaderMap, + Json(dto): Json, +) -> Result { + let auth = state + .auth_service + .as_ref() + .ok_or_else(|| AppError::internal_error("Authentication service not configured"))?; + + // The auth middleware validates the access token but doesn't + // expose the session id. Look it up via the refresh cookie — + // same shape logout uses. Refresh cookie is HttpOnly + SameSite, + // so an attacker who has the access token but not the refresh + // cookie (theft window: seconds between token mint and refresh + // cookie install) simply gets 400. + let refresh_token = cookie_auth::extract_cookie_value(&headers, cookie_auth::REFRESH_COOKIE) + .ok_or_else(|| AppError::unauthorized("Refresh cookie required to identify session"))?; + let session_id = auth + .auth_application_service + .get_session_id_by_refresh_token(&refresh_token) + .await? + .ok_or_else(|| AppError::unauthorized("Session not found"))?; + + auth.auth_application_service + .bind_dpop_jkt_to_session(session_id, &dto.dpop_jkt) + .await?; + + Ok(StatusCode::OK) +} + /// OIDC Back-Channel Logout 1.0 receiver. /// /// The IdP POSTs a signed `logout_token` JWT here when a user's SSO diff --git a/src/interfaces/api/handlers/opaque_auth_handler.rs b/src/interfaces/api/handlers/opaque_auth_handler.rs index 9d7a2439..e6870ad7 100644 --- a/src/interfaces/api/handlers/opaque_auth_handler.rs +++ b/src/interfaces/api/handlers/opaque_auth_handler.rs @@ -537,6 +537,11 @@ pub struct OpaqueLoginKe3Dto { pub exchange_id: ExchangeId, #[serde(rename = "finishLoginRequest")] pub finish_login_request: String, + /// DPoP JWK thumbprint the client generated at page load. When + /// present, binds the new session to a browser-held keypair (RFC + /// 9449). Absent → session created unbound. See `docs/plan/dpop.md`. + #[serde(default, rename = "dpopJkt", alias = "dpop_jkt")] + pub dpop_jkt: Option, } /// KE1: user lookup → envelope fetch → `ServerLogin::start` → stash @@ -764,7 +769,7 @@ pub async fn login_ke3( // we don't want to have flipped the migration flag for a user // whose login didn't actually complete. let session = auth - .mint_session_for_authenticated_user(user) + .mint_session_for_authenticated_user(user, dto.dpop_jkt) .await .map_err(AppError::from)?;