feat(DPoP): bing ceremony on login

This commit is contained in:
Edouard Vanbelle
2026-08-08 14:15:26 +02:00
parent fed265c70f
commit 8b79e26329
10 changed files with 394 additions and 11 deletions
+7
View File
@@ -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<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
+11
View File
@@ -473,6 +473,17 @@ pub trait SessionStoragePort: Send + Sync + 'static {
issuer: &str,
subject: &str,
) -> Result<Option<Uuid>, 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>;
}
// ============================================================================
@@ -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<String, &'static str> {
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<String>,
) -> Result<AuthResponseDto, DomainError> {
// 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<UserFlags, DomainError> {
// Single-flight: concurrent misses for the same user coalesce
// into ONE storage lookup; errors are never cached (same herd
@@ -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<T> = Result<T, SessionRepositoryError>;
@@ -25,6 +32,11 @@ impl From<SessionRepositoryError> 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<u64>;
/// 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<()>;
}
@@ -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)
}
}
@@ -55,6 +55,7 @@ pub fn auth_protected_routes() -> Router<Arc<AppState>> {
// 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<Arc<AppState>>,
CurrentUserId(_user_id): CurrentUserId,
headers: HeaderMap,
Json(dto): Json<DpopBindDto>,
) -> Result<StatusCode, AppError> {
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
@@ -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<String>,
}
/// 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)?;