feat(DPoP): bing ceremony on login
This commit is contained in:
@@ -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