feat(DPoP): bing ceremony on login
This commit is contained in:
@@ -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)]
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user