diff --git a/frontend/src/lib/api/endpoints/auth.ts b/frontend/src/lib/api/endpoints/auth.ts index 12be86f5..d50408c2 100644 --- a/frontend/src/lib/api/endpoints/auth.ts +++ b/frontend/src/lib/api/endpoints/auth.ts @@ -36,9 +36,25 @@ const JSON_HEADERS = { 'Content-Type': 'application/json' }; * a 401 here just means "not logged in" and must not trigger the global * refresh-and-redirect (which would bounce the app in a refresh loop on the * unauthenticated initial load). Returns null when unauthenticated. + * + * Attaches a DPoP proof manually — under `OXICLOUD_DPOP_MODE=required` a + * BOUND session that presents no proof gets 401'd by the middleware + * (Gate 9), and this probe fires on every SPA bootstrap for authenticated + * users. Without the proof, the session load loop would always land in + * "not logged in" on fresh page loads even though cookies are still valid. + * Failure to build a proof (no keypair, missing WebCrypto) falls back to a + * headerless request — the server still accepts it for unbound sessions. */ export async function fetchMe(): Promise { - const res = await fetch('/api/auth/me', { credentials: 'same-origin' }); + let dpop: string | null = null; + try { + const { buildDpopProof } = await import('$lib/auth/dpop-proof'); + dpop = await buildDpopProof('GET', `${location.origin}/api/auth/me`); + } catch { + /* proof unavailable → send without header; unbound sessions still accept */ + } + const headers: HeadersInit = dpop ? { DPoP: dpop } : {}; + const res = await fetch('/api/auth/me', { credentials: 'same-origin', headers }); if (res.status === 401) return null; if (!res.ok) throw new Error(`/api/auth/me failed: ${res.status}`); return (await res.json()) as User; diff --git a/src/application/dtos/user_dto.rs b/src/application/dtos/user_dto.rs index 4943e532..e2580102 100644 --- a/src/application/dtos/user_dto.rs +++ b/src/application/dtos/user_dto.rs @@ -456,6 +456,14 @@ pub struct CurrentUser { pub email: Arc, #[schema(value_type = String)] pub role: SmolStr, + /// DPoP session-binding thumbprint threaded from the JWT's + /// RFC 9449 §5 `cnf.jkt` claim. `None` for unbound sessions + /// (app passwords, NC clients, pre-DPoP). The DPoP middleware + /// reads it to enforce "bound → proof required" from an + /// already-validated token — no session-row lookup on the + /// hot path (see `docs/plan/dpop.md` Gate 9). + #[serde(skip)] + pub dpop_jkt: Option, } // ============================================================================ diff --git a/src/application/ports/auth_ports.rs b/src/application/ports/auth_ports.rs index 414de2d6..d3c81373 100644 --- a/src/application/ports/auth_ports.rs +++ b/src/application/ports/auth_ports.rs @@ -57,6 +57,12 @@ pub struct TokenClaims { pub email: Arc, /// User role pub role: String, + /// RFC 9449 §5 confirmation-key thumbprint — the JWK thumbprint + /// of the DPoP keypair this session was bound to at login. `None` + /// for unbound sessions (app passwords, NC clients, pre-DPoP). + /// The DPoP middleware reads it from the already-validated token + /// (no DB round trip) to enforce "bound session → proof required". + pub dpop_jkt: Option, } /// Port for JWT token operations. @@ -64,8 +70,19 @@ pub struct TokenClaims { /// This trait abstracts token generation and validation, allowing the domain /// layer to remain independent of specific JWT implementations. pub trait TokenServicePort: Send + Sync + 'static { - /// Generate an access token for a user - fn generate_access_token(&self, user: &User) -> Result; + /// Generate an access token for a user. + /// + /// `dpop_jkt` — if `Some`, the token carries an RFC 9449 §5 + /// `cnf.jkt` claim binding it to the browser-held keypair whose + /// public JWK hashes to this thumbprint. Callers pass + /// `session.dpop_jkt()` from the Session being minted; unbound + /// sessions (app passwords, NC clients, pre-DPoP) pass `None` + /// and get a plain token the middleware exempts from DPoP. + fn generate_access_token( + &self, + user: &User, + dpop_jkt: Option<&str>, + ) -> Result; /// Validate a token and extract its claims. /// diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index a8407204..533c33b5 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -1051,8 +1051,37 @@ impl AuthApplicationService { // (benches/ROUND12.md §2, 4.45x). user.register_login(); - // Generate tokens using the injected token service - let access_token = self.token_service.generate_access_token(&user)?; + // Validate DPoP thumbprint FIRST — the same validated value + // has to flow into both the JWT `cnf.jkt` claim (RFC 9449 §5) + // and the session row's `dpop_jkt` column. Reject-before-mint + // avoids issuing a token whose confirmation-key would be + // rejected by the very next request's DPoP middleware. + let validated_jkt = match dpop_jkt.as_deref() { + Some(jkt) => Some(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)", + ) + })?), + None => None, + }; + + // Generate tokens using the injected token service. The + // access token carries the `cnf.jkt` binding when present, + // so the DPoP middleware can enforce "bound → proof required" + // straight from the already-validated JWT — no session-row + // lookup on the hot path. + let access_token = self + .token_service + .generate_access_token(&user, validated_jkt.as_deref())?; let refresh_token = self.token_service.generate_refresh_token(); @@ -1068,22 +1097,8 @@ 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); + if let Some(jkt) = validated_jkt { + session = session.with_dpop_jkt(jkt); } self.session_storage.create_session(session).await?; @@ -1330,7 +1345,12 @@ impl AuthApplicationService { user.mark_email_verified(); self.user_storage.mark_email_verified(user.id()).await?; - let access_token = self.token_service.generate_access_token(&user)?; + // Magic-link redemption is a GET redirect — no way to + // thread `dpop_jkt` into a GET body. Session is minted + // unbound; the SPA calls `POST /api/auth/dpop/bind` + // post-redirect to bind it (see Gate 3). Token accordingly + // ships without `cnf.jkt`. + let access_token = self.token_service.generate_access_token(&user, None)?; let refresh_token = self.token_service.generate_refresh_token(); let session = Session::new( user.id(), @@ -1416,6 +1436,11 @@ impl AuthApplicationService { username: std::sync::Arc::from(user.username().unwrap_or("")), email: std::sync::Arc::from(user.email()), role: smol_str::SmolStr::new_static(user.role().as_str()), + // `verify_credentials` is only called from paths that + // don't need per-session DPoP context (admin/setup + // flows); the DPoP middleware never reads CurrentUser + // populated by this method. Leaving None is safe. + dpop_jkt: None, }) } @@ -1474,8 +1499,14 @@ impl AuthApplicationService { )); } - // Generate new tokens - let access_token = self.token_service.generate_access_token(&user)?; + // Generate new tokens. Inherit the DPoP binding from the + // parent session so the refreshed access token carries the + // same `cnf.jkt` — otherwise every refresh would silently + // downgrade to unbound and the next request would 401 under + // Gate 9 enforcement (see Gate 7). + let access_token = self + .token_service + .generate_access_token(&user, session.dpop_jkt())?; let new_refresh_token = self.token_service.generate_refresh_token(); // New session inherits the family_id so reuse of any ancestor triggers @@ -4233,8 +4264,13 @@ impl AuthApplicationService { }); } - // 6. Issue internal tokens (same as regular login) - let access_token = self.token_service.generate_access_token(&user)?; + // 6. Issue internal tokens (same as regular login). OIDC + // callback is a GET redirect — no way to thread `dpop_jkt` + // through the browser's redirect chain. Session is minted + // unbound; the SPA calls `POST /api/auth/dpop/bind` post- + // redirect to bind it (see Gate 3). Token accordingly ships + // without `cnf.jkt`. + let access_token = self.token_service.generate_access_token(&user, None)?; let refresh_token = self.token_service.generate_refresh_token(); let mut session = Session::new( diff --git a/src/application/services/device_auth_service.rs b/src/application/services/device_auth_service.rs index 1553ca75..1fbdf5ce 100644 --- a/src/application/services/device_auth_service.rs +++ b/src/application/services/device_auth_service.rs @@ -175,8 +175,12 @@ impl DeviceAuthService { // Fetch user to generate tokens let user = self.user_storage.get_user_by_id(user_id).await?; - // Generate internal JWT access token + refresh token - let access_token = self.token_service.generate_access_token(&user)?; + // Generate internal JWT access token + refresh token. Device- + // authorization sessions (RFC 8628) are for CLI / TV / device + // clients that don't run WebCrypto — always unbound (`None` + // for the `dpop_jkt` param), which the DPoP middleware exempts + // from proof requirements. See `docs/plan/dpop.md` Gate 9. + let access_token = self.token_service.generate_access_token(&user, None)?; let refresh_token = self.token_service.generate_refresh_token(); // Persist refresh token as a session diff --git a/src/bin/dpop-hurl-helper.rs b/src/bin/dpop-hurl-helper.rs index 3a0ed0d8..140805e4 100644 --- a/src/bin/dpop-hurl-helper.rs +++ b/src/bin/dpop-hurl-helper.rs @@ -226,6 +226,7 @@ async fn opaque_login( base: &str, username: &str, password: &str, + dpop_jkt: Option<&str>, ) -> Result<(String, String), String> { // Fetch server params so client-side Argon2 matches. Per Phase B // the envelope's OWN KSF is authoritative for that user (via @@ -295,10 +296,19 @@ async fn opaque_login( // URL_SAFE_NO_PAD on the wire — matches what the SPA sends and // what the server's handler prefers (accepts both, but this is // the canonical form). - let ke3_body = json!({ + // Include `dpopJkt` when set so the server binds the session + // to the browser-held (well, test-held) keypair via Gate 3. + // Downstream scenarios can then exercise bound-session paths. + let mut ke3_body = json!({ "exchangeId": ke1.exchange_id, "finishLoginRequest": B64_URL_NO_PAD.encode(login_finish.message.serialize()), }); + if let Some(jkt) = dpop_jkt { + ke3_body + .as_object_mut() + .unwrap() + .insert("dpopJkt".to_string(), json!(jkt)); + } let ke3_res = http .post(format!("{base}/api/auth/opaque/login/ke3")) .json(&ke3_body) @@ -412,26 +422,19 @@ async fn main() -> ExitCode { Err(e) => return fail(format!("build reqwest client: {e}")), }; - // ── 1. Log in via OPAQUE (session created unbound — Gate 3 - // requires the client to send `dpop_jkt` in the login - // body; this helper doesn't, so we get the fail-open - // unbound path). Bind support gets tested via - // `POST /api/auth/dpop/bind` in a later gate. + // ── 1. Mint the persistent keypair FIRST, compute its JWK + // thumbprint, then log in via OPAQUE passing that + // thumbprint so the resulting session is bound (Gate 3). + // That way subsequent scenarios exercise the bound-path + // enforcement Gate 9 lit up: bound session + missing + // proof → 401, wrong-jkt proof → 401 `jkt_mismatch`. // // OPAQUE (not legacy) because `opaque-hurl-helper` // migrates `admin` earlier in `run.sh`, after which // legacy login 403s with Phase-4 refusal — and once - // OPAQUE-only mode ships (`docs/plan/opaque-only.md`) - // there IS no legacy path anyway. - let (access, _refresh) = match opaque_login(&http, base, &username, &password).await { - Ok(t) => t, - Err(e) => return fail(e), - }; - + // OPAQUE-only mode ships there IS no legacy path anyway. let keys = KeyBundle::fresh(); let jkt = { - // Compute expected thumbprint for logging — server ignores - // at this gate but future scenarios will compare. let canonical = format!( r#"{{"crv":"P-256","kty":"EC","x":"{}","y":"{}"}}"#, keys.jwk_x_b64, keys.jwk_y_b64 @@ -440,6 +443,12 @@ async fn main() -> ExitCode { }; eprintln!("dpop-hurl-helper: keypair jkt={jkt}"); + let (access, _refresh) = match opaque_login(&http, base, &username, &password, Some(&jkt)).await + { + Ok(t) => t, + Err(e) => return fail(e), + }; + let mut nonce: Option = None; // ── Scenario 1: happy path — bootstrap (no nonce) → challenge @@ -670,19 +679,70 @@ async fn main() -> ExitCode { } eprintln!("dpop-hurl-helper: scenario 8 (malformed) ✓"); - // ── Scenario 9: no proof at all on a bound-if-required path. - // In opportunistic mode this passes through; in required - // mode the session is unbound (`dpop_jkt IS NULL`) so it - // STILL passes through. Server-side gate 9 will flip this - // once session-context enforcement lands. + // ── Scenario 9: bound session with NO proof — Gate 9 + // enforcement. Under `required` mode this must 401 with a + // `use_dpop_nonce` challenge (server treats missing proof + // on a bound session the same shape as a nonce-challenge + // to nudge the client back onto the DPoP path). Under + // `opportunistic` mode this would 200 with only a warning + // audit line. Test env pins `required` (see + // `tests/common/server.env`). let res_no_proof = match http.get(&url).bearer_auth(&access).send().await { Ok(r) => r, Err(e) => return fail(format!("no_proof send: {e}")), }; - if let Err(e) = expect_status("no_proof_unbound_session", &res_no_proof, 200) { + if let Err(e) = expect_status("no_proof_bound_session_required", &res_no_proof, 401) { return fail(e); } - eprintln!("dpop-hurl-helper: scenario 9 (no proof, unbound session → pass) ✓"); + eprintln!("dpop-hurl-helper: scenario 9 (no proof, bound session, required → 401) ✓"); + + // ── Scenario 10: bound session, valid proof shape but signed + // by a DIFFERENT keypair than the session was bound to. + // Verifier fires `jkt_mismatch` → 401. The classic attacker + // scenario: cookie stolen, attacker mints their own DPoP + // keypair, valid proof shape but wrong key. + let rogue_keys = KeyBundle { + signing_key: { + let mut bytes = [0u8; 32]; + for (i, b) in bytes.iter_mut().enumerate() { + *b = ((i as u8).wrapping_mul(41)).wrapping_add(7); + } + SigningKey::from_bytes(&bytes.into()).expect("valid P-256 scalar") + }, + jwk_x_b64: String::new(), + jwk_y_b64: String::new(), + }; + // Rebuild x/y for the rogue key. + let rogue_enc = rogue_keys + .signing_key + .verifying_key() + .to_encoded_point(false); + let rogue_keys = KeyBundle { + signing_key: rogue_keys.signing_key, + jwk_x_b64: B64_URL_NO_PAD.encode(rogue_enc.x().unwrap()), + jwk_y_b64: B64_URL_NO_PAD.encode(rogue_enc.y().unwrap()), + }; + let rogue_proof = build_proof( + &rogue_keys, + "GET", + &url, + nonce.as_deref(), + &ProofOverrides::default(), + ); + let res_rogue = match http + .get(&url) + .bearer_auth(&access) + .header("DPoP", rogue_proof) + .send() + .await + { + Ok(r) => r, + Err(e) => return fail(format!("rogue-key send: {e}")), + }; + if let Err(e) = expect_status("wrong_jkt_bound_session", &res_rogue, 401) { + return fail(e); + } + eprintln!("dpop-hurl-helper: scenario 10 (bound session + wrong-jkt proof → 401) ✓"); eprintln!("dpop-hurl-helper: all scenarios passed"); ExitCode::SUCCESS diff --git a/src/infrastructure/services/jwt_service.rs b/src/infrastructure/services/jwt_service.rs index be04d3b1..9f8385a2 100644 --- a/src/infrastructure/services/jwt_service.rs +++ b/src/infrastructure/services/jwt_service.rs @@ -45,6 +45,23 @@ struct JwtClaims { pub email: Arc, /// User role for authorization checks pub role: String, + /// RFC 9449 §5 confirmation-key claim: JWK thumbprint of the + /// browser-held DPoP keypair the session was bound to at login. + /// `None` for unbound sessions (app passwords, Nextcloud clients, + /// pre-DPoP sessions). Populated at token mint time from + /// `session.dpop_jkt`; the DPoP middleware reads it to enforce + /// "bound session → proof required" without a DB round trip. + /// + /// Serialised as `{"cnf": {"jkt": "..."}}` to match RFC 9449. + #[serde(skip_serializing_if = "Option::is_none")] + pub cnf: Option, +} + +/// RFC 9449 §5 confirmation-key wrapper. Only the `jkt` member is +/// used today; future extensions (`x5t#S256`, etc.) would live here. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct CnfClaim { + pub jkt: String, } impl From for TokenClaims { @@ -64,6 +81,7 @@ impl From for TokenClaims { username: claims.username, email: claims.email, role: claims.role, + dpop_jkt: claims.cnf.map(|c| c.jkt), } } } @@ -175,7 +193,11 @@ impl JwtTokenService { } impl TokenServicePort for JwtTokenService { - fn generate_access_token(&self, user: &User) -> Result { + fn generate_access_token( + &self, + user: &User, + dpop_jkt: Option<&str>, + ) -> Result { let now = Utc::now().timestamp(); // Log information for debugging @@ -194,6 +216,9 @@ impl TokenServicePort for JwtTokenService { username: Arc::from(user.username().unwrap_or("")), email: Arc::from(user.email()), role: user.role().as_str().to_string(), + cnf: dpop_jkt.map(|jkt| CnfClaim { + jkt: jkt.to_string(), + }), }; // Log JWT claims for debugging @@ -306,7 +331,7 @@ mod tests { let user = create_test_user(); let token = service - .generate_access_token(&user) + .generate_access_token(&user, None) .expect("Should generate token"); let claims = service @@ -345,7 +370,7 @@ mod tests { let user = create_test_user(); let token = service - .generate_access_token(&user) + .generate_access_token(&user, None) .expect("Should generate token"); // First call: cache miss — performs full HMAC verification @@ -372,7 +397,7 @@ mod tests { 86400, ); let token = service - .generate_access_token(&create_test_user()) + .generate_access_token(&create_test_user(), None) .expect("Should generate token"); // Miss populates the cache; hit must hand back the very same diff --git a/src/interfaces/middleware/auth.rs b/src/interfaces/middleware/auth.rs index 0c128f94..7f2a70ec 100644 --- a/src/interfaces/middleware/auth.rs +++ b/src/interfaces/middleware/auth.rs @@ -224,6 +224,7 @@ pub async fn auth_middleware( username: Arc::clone(&claims.username), email: Arc::clone(&claims.email), role, + dpop_jkt: claims.dpop_jkt.clone(), }); request.extensions_mut().insert(current_user); tracing::Span::current() @@ -267,11 +268,16 @@ pub async fn auth_middleware( "App password authentication successful for user: {}", uname ); + // App-password sessions are always unbound — + // they belong to NC clients / CLI / mobile + // tools without WebCrypto. DPoP middleware + // exempts them. let current_user = Arc::new(CurrentUser { id: user_id, username: uname, email, role, + dpop_jkt: None, }); request.extensions_mut().insert(current_user); tracing::Span::current() @@ -341,6 +347,7 @@ pub async fn auth_middleware( username: Arc::clone(&claims.username), email: Arc::clone(&claims.email), role, + dpop_jkt: claims.dpop_jkt.clone(), }); request.extensions_mut().insert(current_user); request.extensions_mut().insert(CookieAuthenticated); diff --git a/src/interfaces/middleware/dpop.rs b/src/interfaces/middleware/dpop.rs index f975070a..ac34eec5 100644 --- a/src/interfaces/middleware/dpop.rs +++ b/src/interfaces/middleware/dpop.rs @@ -96,7 +96,7 @@ pub async fn require_dpop_layer( // No authenticated user → pass through (upstream auth layer // already handled or will handle the 401). We only concern // ourselves with proof-carrying requests on authenticated paths. - let Some(_current_user) = request.extensions().get::>() else { + let Some(current_user) = request.extensions().get::>().cloned() else { return next.run(request).await; }; @@ -106,14 +106,44 @@ pub async fn require_dpop_layer( .and_then(|v| v.to_str().ok()) .map(str::to_owned); - // GATE 5 SCOPE: session-level `dpop_jkt` lookup is deferred (see - // module docstring). For opportunistic mode: - // - proof present → verify with expected_jkt=None (accept any - // jkt shape; still catches malformed/bad-sig/wrong-htu bugs) - // - proof absent → pass through, no warning yet - // For required mode: same for now — Gate 9 flips to real - // per-session enforcement once session context is wired. + // Gate 9 enforcement — the session's binding (from the JWT's + // `cnf.jkt` claim, populated at token mint time from + // `session.dpop_jkt`) tells us whether a proof is REQUIRED: + // + // * unbound session (`dpop_jkt IS NONE`) — proof optional. + // Covers app passwords, NC clients, pre-DPoP sessions. + // * bound session — proof MANDATORY in required mode; a + // warning-only signal in opportunistic mode so operators + // can spot stale SPA versions before flipping enforcement. + let expected_jkt = current_user.dpop_jkt.as_deref(); let Some(proof) = dpop_header else { + match (mode, expected_jkt) { + (DpopMode::Required, Some(_)) => { + tracing::info!( + target: "audit", + event = "dpop.verify_failed", + reason = "proof_missing_on_bound_session", + caller_id = %current_user.id, + path = %request.uri().path(), + "👮🏻‍♂️ DPoP required: bound session request has no proof", + ); + return nonce_challenge_response(&nonce_service); + } + (DpopMode::Opportunistic, Some(_)) => { + // Warning-only — telemetry for the rollout window. + // Emit the signal so operators can decide when to + // flip default to `required`; the request still + // completes so old clients don't break. + tracing::info!( + target: "audit", + event = "dpop.header_missing_but_session_bound", + caller_id = %current_user.id, + path = %request.uri().path(), + "⚠️ DPoP: bound session sent request without a proof", + ); + } + _ => { /* unbound session or off mode — nothing to do */ } + } let response = next.run(request).await; return stamp_current_nonce(response, &nonce_service); }; @@ -136,7 +166,14 @@ pub async fn require_dpop_layer( htm: &method, htu: &htu, now_secs, - expected_jkt: None, // Session-level pin comes in a later gate + // Gate 9: pin to the session's binding when present. The + // verifier returns `JktMismatch` if the proof's public + // key thumbprint doesn't match — an attacker who stole a + // bound cookie AND generated their own DPoP keypair fails + // here. `None` means the session was minted unbound so + // any well-formed proof passes the jkt check (still gets + // htm/htu/nonce/replay verification). + expected_jkt, }; match verify_proof(&proof, &ctx) { Ok(verified) => { diff --git a/src/interfaces/nextcloud/basic_auth_middleware.rs b/src/interfaces/nextcloud/basic_auth_middleware.rs index 7e088f17..dc37620a 100644 --- a/src/interfaces/nextcloud/basic_auth_middleware.rs +++ b/src/interfaces/nextcloud/basic_auth_middleware.rs @@ -203,11 +203,15 @@ pub async fn basic_auth_middleware( // `Arc` extension AND `NcSession.user` (the old // code built the struct, cloned it for the extension, then // moved the original — 2-3 String allocs per request). + // Nextcloud clients are always unbound — they authenticate + // with app passwords via Basic Auth, no WebCrypto, no DPoP. + // Middleware exempts unbound sessions per Gate 9 design. let current_user = Arc::new(CurrentUser { id: user_id, username: uname, email, role, + dpop_jkt: None, }); // ── Resolve chroot from the Basic Auth drive marker ───── diff --git a/src/interfaces/nextcloud/login_v2_handler.rs b/src/interfaces/nextcloud/login_v2_handler.rs index c8951b47..26a7d8d0 100644 --- a/src/interfaces/nextcloud/login_v2_handler.rs +++ b/src/interfaces/nextcloud/login_v2_handler.rs @@ -342,6 +342,9 @@ pub async fn handle_oidc_login_completion( username: std::sync::Arc::from(username), email: std::sync::Arc::from(user_dto.email.as_str()), role: smol_str::SmolStr::new(&user_dto.role), + // NC login-flow-v2 mints an app password — no browser, no + // WebCrypto, always unbound. DPoP middleware exempts. + dpop_jkt: None, }; let drives = match state @@ -553,6 +556,9 @@ pub async fn handle_drive_pick( username: std::sync::Arc::from(username.as_str()), email: std::sync::Arc::from(user_dto.email.as_str()), role: smol_str::SmolStr::new(&user_dto.role), + // NC login-flow-v2 mints an app password — no browser, no + // WebCrypto, always unbound. DPoP middleware exempts. + dpop_jkt: None, }; let _folder = match state