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
+64 -2
View File
@@ -63,7 +63,65 @@ export async function tryRefresh(): Promise<boolean> {
}
}
/**
* 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<string | null> {
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<boolean> {
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<AuthResponse> {
// 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
@@ -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
);
+13 -2
View File
@@ -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<AuthResponse> {
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);
+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)?;