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
@@ -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)
}
}