feat(DPoP): check requests and 401 on failure
This commit is contained in:
@@ -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
|
* 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
|
* refresh-and-redirect (which would bounce the app in a refresh loop on the
|
||||||
* unauthenticated initial load). Returns null when unauthenticated.
|
* 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<User | null> {
|
export async function fetchMe(): Promise<User | null> {
|
||||||
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.status === 401) return null;
|
||||||
if (!res.ok) throw new Error(`/api/auth/me failed: ${res.status}`);
|
if (!res.ok) throw new Error(`/api/auth/me failed: ${res.status}`);
|
||||||
return (await res.json()) as User;
|
return (await res.json()) as User;
|
||||||
|
|||||||
@@ -456,6 +456,14 @@ pub struct CurrentUser {
|
|||||||
pub email: Arc<str>,
|
pub email: Arc<str>,
|
||||||
#[schema(value_type = String)]
|
#[schema(value_type = String)]
|
||||||
pub role: SmolStr,
|
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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ pub struct TokenClaims {
|
|||||||
pub email: Arc<str>,
|
pub email: Arc<str>,
|
||||||
/// User role
|
/// User role
|
||||||
pub role: String,
|
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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Port for JWT token operations.
|
/// Port for JWT token operations.
|
||||||
@@ -64,8 +70,19 @@ pub struct TokenClaims {
|
|||||||
/// This trait abstracts token generation and validation, allowing the domain
|
/// This trait abstracts token generation and validation, allowing the domain
|
||||||
/// layer to remain independent of specific JWT implementations.
|
/// layer to remain independent of specific JWT implementations.
|
||||||
pub trait TokenServicePort: Send + Sync + 'static {
|
pub trait TokenServicePort: Send + Sync + 'static {
|
||||||
/// Generate an access token for a user
|
/// Generate an access token for a user.
|
||||||
fn generate_access_token(&self, user: &User) -> Result<String, DomainError>;
|
///
|
||||||
|
/// `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<String, DomainError>;
|
||||||
|
|
||||||
/// Validate a token and extract its claims.
|
/// Validate a token and extract its claims.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -1051,8 +1051,37 @@ impl AuthApplicationService {
|
|||||||
// (benches/ROUND12.md §2, 4.45x).
|
// (benches/ROUND12.md §2, 4.45x).
|
||||||
user.register_login();
|
user.register_login();
|
||||||
|
|
||||||
// Generate tokens using the injected token service
|
// Validate DPoP thumbprint FIRST — the same validated value
|
||||||
let access_token = self.token_service.generate_access_token(&user)?;
|
// 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();
|
let refresh_token = self.token_service.generate_refresh_token();
|
||||||
|
|
||||||
@@ -1068,22 +1097,8 @@ impl AuthApplicationService {
|
|||||||
self.token_service.refresh_token_expiry_days(),
|
self.token_service.refresh_token_expiry_days(),
|
||||||
Uuid::new_v4(),
|
Uuid::new_v4(),
|
||||||
);
|
);
|
||||||
if let Some(jkt) = dpop_jkt {
|
if let Some(jkt) = validated_jkt {
|
||||||
let validated = validate_dpop_jkt(&jkt).map_err(|e| {
|
session = session.with_dpop_jkt(jkt);
|
||||||
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?;
|
self.session_storage.create_session(session).await?;
|
||||||
@@ -1330,7 +1345,12 @@ impl AuthApplicationService {
|
|||||||
user.mark_email_verified();
|
user.mark_email_verified();
|
||||||
self.user_storage.mark_email_verified(user.id()).await?;
|
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 refresh_token = self.token_service.generate_refresh_token();
|
||||||
let session = Session::new(
|
let session = Session::new(
|
||||||
user.id(),
|
user.id(),
|
||||||
@@ -1416,6 +1436,11 @@ impl AuthApplicationService {
|
|||||||
username: std::sync::Arc::from(user.username().unwrap_or("")),
|
username: std::sync::Arc::from(user.username().unwrap_or("")),
|
||||||
email: std::sync::Arc::from(user.email()),
|
email: std::sync::Arc::from(user.email()),
|
||||||
role: smol_str::SmolStr::new_static(user.role().as_str()),
|
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
|
// Generate new tokens. Inherit the DPoP binding from the
|
||||||
let access_token = self.token_service.generate_access_token(&user)?;
|
// 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();
|
let new_refresh_token = self.token_service.generate_refresh_token();
|
||||||
|
|
||||||
// New session inherits the family_id so reuse of any ancestor triggers
|
// 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)
|
// 6. Issue internal tokens (same as regular login). OIDC
|
||||||
let access_token = self.token_service.generate_access_token(&user)?;
|
// 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 refresh_token = self.token_service.generate_refresh_token();
|
||||||
|
|
||||||
let mut session = Session::new(
|
let mut session = Session::new(
|
||||||
|
|||||||
@@ -175,8 +175,12 @@ impl DeviceAuthService {
|
|||||||
// Fetch user to generate tokens
|
// Fetch user to generate tokens
|
||||||
let user = self.user_storage.get_user_by_id(user_id).await?;
|
let user = self.user_storage.get_user_by_id(user_id).await?;
|
||||||
|
|
||||||
// Generate internal JWT access token + refresh token
|
// Generate internal JWT access token + refresh token. Device-
|
||||||
let access_token = self.token_service.generate_access_token(&user)?;
|
// 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();
|
let refresh_token = self.token_service.generate_refresh_token();
|
||||||
|
|
||||||
// Persist refresh token as a session
|
// Persist refresh token as a session
|
||||||
|
|||||||
+82
-22
@@ -226,6 +226,7 @@ async fn opaque_login(
|
|||||||
base: &str,
|
base: &str,
|
||||||
username: &str,
|
username: &str,
|
||||||
password: &str,
|
password: &str,
|
||||||
|
dpop_jkt: Option<&str>,
|
||||||
) -> Result<(String, String), String> {
|
) -> Result<(String, String), String> {
|
||||||
// Fetch server params so client-side Argon2 matches. Per Phase B
|
// Fetch server params so client-side Argon2 matches. Per Phase B
|
||||||
// the envelope's OWN KSF is authoritative for that user (via
|
// 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
|
// URL_SAFE_NO_PAD on the wire — matches what the SPA sends and
|
||||||
// what the server's handler prefers (accepts both, but this is
|
// what the server's handler prefers (accepts both, but this is
|
||||||
// the canonical form).
|
// 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,
|
"exchangeId": ke1.exchange_id,
|
||||||
"finishLoginRequest": B64_URL_NO_PAD.encode(login_finish.message.serialize()),
|
"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
|
let ke3_res = http
|
||||||
.post(format!("{base}/api/auth/opaque/login/ke3"))
|
.post(format!("{base}/api/auth/opaque/login/ke3"))
|
||||||
.json(&ke3_body)
|
.json(&ke3_body)
|
||||||
@@ -412,26 +422,19 @@ async fn main() -> ExitCode {
|
|||||||
Err(e) => return fail(format!("build reqwest client: {e}")),
|
Err(e) => return fail(format!("build reqwest client: {e}")),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── 1. Log in via OPAQUE (session created unbound — Gate 3
|
// ── 1. Mint the persistent keypair FIRST, compute its JWK
|
||||||
// requires the client to send `dpop_jkt` in the login
|
// thumbprint, then log in via OPAQUE passing that
|
||||||
// body; this helper doesn't, so we get the fail-open
|
// thumbprint so the resulting session is bound (Gate 3).
|
||||||
// unbound path). Bind support gets tested via
|
// That way subsequent scenarios exercise the bound-path
|
||||||
// `POST /api/auth/dpop/bind` in a later gate.
|
// enforcement Gate 9 lit up: bound session + missing
|
||||||
|
// proof → 401, wrong-jkt proof → 401 `jkt_mismatch`.
|
||||||
//
|
//
|
||||||
// OPAQUE (not legacy) because `opaque-hurl-helper`
|
// OPAQUE (not legacy) because `opaque-hurl-helper`
|
||||||
// migrates `admin` earlier in `run.sh`, after which
|
// migrates `admin` earlier in `run.sh`, after which
|
||||||
// legacy login 403s with Phase-4 refusal — and once
|
// legacy login 403s with Phase-4 refusal — and once
|
||||||
// OPAQUE-only mode ships (`docs/plan/opaque-only.md`)
|
// OPAQUE-only mode ships there IS no legacy path anyway.
|
||||||
// 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),
|
|
||||||
};
|
|
||||||
|
|
||||||
let keys = KeyBundle::fresh();
|
let keys = KeyBundle::fresh();
|
||||||
let jkt = {
|
let jkt = {
|
||||||
// Compute expected thumbprint for logging — server ignores
|
|
||||||
// at this gate but future scenarios will compare.
|
|
||||||
let canonical = format!(
|
let canonical = format!(
|
||||||
r#"{{"crv":"P-256","kty":"EC","x":"{}","y":"{}"}}"#,
|
r#"{{"crv":"P-256","kty":"EC","x":"{}","y":"{}"}}"#,
|
||||||
keys.jwk_x_b64, keys.jwk_y_b64
|
keys.jwk_x_b64, keys.jwk_y_b64
|
||||||
@@ -440,6 +443,12 @@ async fn main() -> ExitCode {
|
|||||||
};
|
};
|
||||||
eprintln!("dpop-hurl-helper: keypair jkt={jkt}");
|
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<String> = None;
|
let mut nonce: Option<String> = None;
|
||||||
|
|
||||||
// ── Scenario 1: happy path — bootstrap (no nonce) → challenge
|
// ── Scenario 1: happy path — bootstrap (no nonce) → challenge
|
||||||
@@ -670,19 +679,70 @@ async fn main() -> ExitCode {
|
|||||||
}
|
}
|
||||||
eprintln!("dpop-hurl-helper: scenario 8 (malformed) ✓");
|
eprintln!("dpop-hurl-helper: scenario 8 (malformed) ✓");
|
||||||
|
|
||||||
// ── Scenario 9: no proof at all on a bound-if-required path.
|
// ── Scenario 9: bound session with NO proof — Gate 9
|
||||||
// In opportunistic mode this passes through; in required
|
// enforcement. Under `required` mode this must 401 with a
|
||||||
// mode the session is unbound (`dpop_jkt IS NULL`) so it
|
// `use_dpop_nonce` challenge (server treats missing proof
|
||||||
// STILL passes through. Server-side gate 9 will flip this
|
// on a bound session the same shape as a nonce-challenge
|
||||||
// once session-context enforcement lands.
|
// 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 {
|
let res_no_proof = match http.get(&url).bearer_auth(&access).send().await {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => return fail(format!("no_proof send: {e}")),
|
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);
|
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");
|
eprintln!("dpop-hurl-helper: all scenarios passed");
|
||||||
ExitCode::SUCCESS
|
ExitCode::SUCCESS
|
||||||
|
|||||||
@@ -45,6 +45,23 @@ struct JwtClaims {
|
|||||||
pub email: Arc<str>,
|
pub email: Arc<str>,
|
||||||
/// User role for authorization checks
|
/// User role for authorization checks
|
||||||
pub role: String,
|
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<CnfClaim>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<JwtClaims> for TokenClaims {
|
impl From<JwtClaims> for TokenClaims {
|
||||||
@@ -64,6 +81,7 @@ impl From<JwtClaims> for TokenClaims {
|
|||||||
username: claims.username,
|
username: claims.username,
|
||||||
email: claims.email,
|
email: claims.email,
|
||||||
role: claims.role,
|
role: claims.role,
|
||||||
|
dpop_jkt: claims.cnf.map(|c| c.jkt),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,7 +193,11 @@ impl JwtTokenService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl TokenServicePort for JwtTokenService {
|
impl TokenServicePort for JwtTokenService {
|
||||||
fn generate_access_token(&self, user: &User) -> Result<String, DomainError> {
|
fn generate_access_token(
|
||||||
|
&self,
|
||||||
|
user: &User,
|
||||||
|
dpop_jkt: Option<&str>,
|
||||||
|
) -> Result<String, DomainError> {
|
||||||
let now = Utc::now().timestamp();
|
let now = Utc::now().timestamp();
|
||||||
|
|
||||||
// Log information for debugging
|
// Log information for debugging
|
||||||
@@ -194,6 +216,9 @@ impl TokenServicePort for JwtTokenService {
|
|||||||
username: Arc::from(user.username().unwrap_or("")),
|
username: Arc::from(user.username().unwrap_or("")),
|
||||||
email: Arc::from(user.email()),
|
email: Arc::from(user.email()),
|
||||||
role: user.role().as_str().to_string(),
|
role: user.role().as_str().to_string(),
|
||||||
|
cnf: dpop_jkt.map(|jkt| CnfClaim {
|
||||||
|
jkt: jkt.to_string(),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Log JWT claims for debugging
|
// Log JWT claims for debugging
|
||||||
@@ -306,7 +331,7 @@ mod tests {
|
|||||||
|
|
||||||
let user = create_test_user();
|
let user = create_test_user();
|
||||||
let token = service
|
let token = service
|
||||||
.generate_access_token(&user)
|
.generate_access_token(&user, None)
|
||||||
.expect("Should generate token");
|
.expect("Should generate token");
|
||||||
|
|
||||||
let claims = service
|
let claims = service
|
||||||
@@ -345,7 +370,7 @@ mod tests {
|
|||||||
|
|
||||||
let user = create_test_user();
|
let user = create_test_user();
|
||||||
let token = service
|
let token = service
|
||||||
.generate_access_token(&user)
|
.generate_access_token(&user, None)
|
||||||
.expect("Should generate token");
|
.expect("Should generate token");
|
||||||
|
|
||||||
// First call: cache miss — performs full HMAC verification
|
// First call: cache miss — performs full HMAC verification
|
||||||
@@ -372,7 +397,7 @@ mod tests {
|
|||||||
86400,
|
86400,
|
||||||
);
|
);
|
||||||
let token = service
|
let token = service
|
||||||
.generate_access_token(&create_test_user())
|
.generate_access_token(&create_test_user(), None)
|
||||||
.expect("Should generate token");
|
.expect("Should generate token");
|
||||||
|
|
||||||
// Miss populates the cache; hit must hand back the very same
|
// Miss populates the cache; hit must hand back the very same
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ pub async fn auth_middleware(
|
|||||||
username: Arc::clone(&claims.username),
|
username: Arc::clone(&claims.username),
|
||||||
email: Arc::clone(&claims.email),
|
email: Arc::clone(&claims.email),
|
||||||
role,
|
role,
|
||||||
|
dpop_jkt: claims.dpop_jkt.clone(),
|
||||||
});
|
});
|
||||||
request.extensions_mut().insert(current_user);
|
request.extensions_mut().insert(current_user);
|
||||||
tracing::Span::current()
|
tracing::Span::current()
|
||||||
@@ -267,11 +268,16 @@ pub async fn auth_middleware(
|
|||||||
"App password authentication successful for user: {}",
|
"App password authentication successful for user: {}",
|
||||||
uname
|
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 {
|
let current_user = Arc::new(CurrentUser {
|
||||||
id: user_id,
|
id: user_id,
|
||||||
username: uname,
|
username: uname,
|
||||||
email,
|
email,
|
||||||
role,
|
role,
|
||||||
|
dpop_jkt: None,
|
||||||
});
|
});
|
||||||
request.extensions_mut().insert(current_user);
|
request.extensions_mut().insert(current_user);
|
||||||
tracing::Span::current()
|
tracing::Span::current()
|
||||||
@@ -341,6 +347,7 @@ pub async fn auth_middleware(
|
|||||||
username: Arc::clone(&claims.username),
|
username: Arc::clone(&claims.username),
|
||||||
email: Arc::clone(&claims.email),
|
email: Arc::clone(&claims.email),
|
||||||
role,
|
role,
|
||||||
|
dpop_jkt: claims.dpop_jkt.clone(),
|
||||||
});
|
});
|
||||||
request.extensions_mut().insert(current_user);
|
request.extensions_mut().insert(current_user);
|
||||||
request.extensions_mut().insert(CookieAuthenticated);
|
request.extensions_mut().insert(CookieAuthenticated);
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ pub async fn require_dpop_layer(
|
|||||||
// No authenticated user → pass through (upstream auth layer
|
// No authenticated user → pass through (upstream auth layer
|
||||||
// already handled or will handle the 401). We only concern
|
// already handled or will handle the 401). We only concern
|
||||||
// ourselves with proof-carrying requests on authenticated paths.
|
// ourselves with proof-carrying requests on authenticated paths.
|
||||||
let Some(_current_user) = request.extensions().get::<Arc<CurrentUser>>() else {
|
let Some(current_user) = request.extensions().get::<Arc<CurrentUser>>().cloned() else {
|
||||||
return next.run(request).await;
|
return next.run(request).await;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -106,14 +106,44 @@ pub async fn require_dpop_layer(
|
|||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.map(str::to_owned);
|
.map(str::to_owned);
|
||||||
|
|
||||||
// GATE 5 SCOPE: session-level `dpop_jkt` lookup is deferred (see
|
// Gate 9 enforcement — the session's binding (from the JWT's
|
||||||
// module docstring). For opportunistic mode:
|
// `cnf.jkt` claim, populated at token mint time from
|
||||||
// - proof present → verify with expected_jkt=None (accept any
|
// `session.dpop_jkt`) tells us whether a proof is REQUIRED:
|
||||||
// jkt shape; still catches malformed/bad-sig/wrong-htu bugs)
|
//
|
||||||
// - proof absent → pass through, no warning yet
|
// * unbound session (`dpop_jkt IS NONE`) — proof optional.
|
||||||
// For required mode: same for now — Gate 9 flips to real
|
// Covers app passwords, NC clients, pre-DPoP sessions.
|
||||||
// per-session enforcement once session context is wired.
|
// * 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 {
|
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;
|
let response = next.run(request).await;
|
||||||
return stamp_current_nonce(response, &nonce_service);
|
return stamp_current_nonce(response, &nonce_service);
|
||||||
};
|
};
|
||||||
@@ -136,7 +166,14 @@ pub async fn require_dpop_layer(
|
|||||||
htm: &method,
|
htm: &method,
|
||||||
htu: &htu,
|
htu: &htu,
|
||||||
now_secs,
|
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) {
|
match verify_proof(&proof, &ctx) {
|
||||||
Ok(verified) => {
|
Ok(verified) => {
|
||||||
|
|||||||
@@ -203,11 +203,15 @@ pub async fn basic_auth_middleware(
|
|||||||
// `Arc<CurrentUser>` extension AND `NcSession.user` (the old
|
// `Arc<CurrentUser>` extension AND `NcSession.user` (the old
|
||||||
// code built the struct, cloned it for the extension, then
|
// code built the struct, cloned it for the extension, then
|
||||||
// moved the original — 2-3 String allocs per request).
|
// 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 {
|
let current_user = Arc::new(CurrentUser {
|
||||||
id: user_id,
|
id: user_id,
|
||||||
username: uname,
|
username: uname,
|
||||||
email,
|
email,
|
||||||
role,
|
role,
|
||||||
|
dpop_jkt: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Resolve chroot from the Basic Auth drive marker ─────
|
// ── Resolve chroot from the Basic Auth drive marker ─────
|
||||||
|
|||||||
@@ -342,6 +342,9 @@ pub async fn handle_oidc_login_completion(
|
|||||||
username: std::sync::Arc::from(username),
|
username: std::sync::Arc::from(username),
|
||||||
email: std::sync::Arc::from(user_dto.email.as_str()),
|
email: std::sync::Arc::from(user_dto.email.as_str()),
|
||||||
role: smol_str::SmolStr::new(&user_dto.role),
|
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
|
let drives = match state
|
||||||
@@ -553,6 +556,9 @@ pub async fn handle_drive_pick(
|
|||||||
username: std::sync::Arc::from(username.as_str()),
|
username: std::sync::Arc::from(username.as_str()),
|
||||||
email: std::sync::Arc::from(user_dto.email.as_str()),
|
email: std::sync::Arc::from(user_dto.email.as_str()),
|
||||||
role: smol_str::SmolStr::new(&user_dto.role),
|
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
|
let _folder = match state
|
||||||
|
|||||||
Reference in New Issue
Block a user