//! OPAQUE aPAKE (RFC 9807) HTTP handlers — Phase 1. //! //! Two round-trips per operation: //! //! ```text //! Registration (session-authenticated): //! POST /api/auth/opaque/register/start //! { registrationRequest: base64 } //! → { registrationResponse: base64 } //! POST /api/auth/opaque/register/finish //! { registrationRecord: base64, ciphersuiteVersion: i16 } //! → 204 No Content //! //! Login (unauth) — LANDS IN A LATER STEP OF PHASE 1 //! POST /api/auth/opaque/login/ke1 //! POST /api/auth/opaque/login/ke3 //! ``` //! //! ## Why session-authenticated for registration //! //! Registration binds an envelope to a user_id. That id has to come //! from somewhere the server trusts — not from the request body //! (attacker could bind an envelope to someone else's account) and //! not from the OPAQUE handshake itself (the OPAQUE handshake IS //! what we're bootstrapping; chicken-and-egg). The pragmatic answer, //! matching Bitwarden / Proton / 1Password: the user proves identity //! via the existing legacy password login (or magic-link, or OIDC) //! ONCE, then registers their OPAQUE envelope from that session. //! Phase 2 wires this as a silent hook after every legacy login. //! //! ## Payload encoding //! //! All OPAQUE messages are opaque byte blobs, round-tripped through //! `serde_json` as a `String` field. The server emits **URL-safe-no-pad** //! base64 (`-`/`_`, no `=`) via `B64.encode(...)` — the WASM client //! (`@serenity-kit/opaque`) rejects standard base64 with an //! `Invalid symbol` error on the first `+`/`/`. On decode the server //! accepts BOTH flavours via `decode_opaque_b64` so the Rust //! `opaque-hurl-helper` test binary (which emits standard) still //! round-trips. Asymmetry is intentional: URL-safe is the compatible //! superset for the client mix we support. //! //! ## Ciphersuite version handshake //! //! `register/finish` requires the client to echo back the //! `ciphersuiteVersion` it minted the envelope under. That must match //! the server's currently-configured version — a mismatch means the //! client cached stale params (or the server rotated the suite). We //! reject with `OpaqueCiphersuiteMismatch` so the SPA can prompt the //! user to refresh and try again. //! //! ## What this handler does NOT do //! //! - Login endpoints (KE1 / KE3) — separate step in Phase 1. //! - Silent-migration integration into legacy `/api/auth/login` — //! Phase 2 concern; this handler ships the ENDPOINT, migration //! plumbs the CALL SITE. //! - Rate limiting — the register endpoints are session-authenticated, //! so an attacker would need a stolen session to reach them; the //! session's issuance path already carries its own rate limit. //! Login endpoints (once shipped) share a budget with legacy login. use std::sync::Arc; use axum::Router; use axum::extract::{Json, State}; use axum::http::StatusCode; use axum::response::IntoResponse; use axum::routing::{get, post}; use base64::Engine as _; use base64::engine::general_purpose::{ STANDARD as B64_STANDARD, URL_SAFE_NO_PAD as B64_URL_NO_PAD, }; /// Decode base64 payloads received from OPAQUE clients, accepting BOTH /// standard (`+`/`/`, padded) and URL-safe-no-pad (`-`/`_`, no padding) /// alphabets. `@serenity-kit/opaque` (the WASM client the SPA uses) /// emits URL-safe-no-pad; the `opaque-hurl-helper` binary and the /// original spec docs use standard. Accepting both means neither /// side has to renormalize. fn decode_opaque_b64(input: &str) -> Result, base64::DecodeError> { let trimmed = input.trim(); B64_STANDARD .decode(trimmed) .or_else(|_| B64_URL_NO_PAD.decode(trimmed)) } /// Encode OPAQUE payloads emitted BY the server. Emits **URL-safe-no-pad** /// (`-`/`_`, no `=`) because the WASM client (`@serenity-kit/opaque` — /// the SPA's OPAQUE library) decodes strictly as URL-safe-no-pad and /// rejects standard base64 with an `Invalid symbol` error on the first /// `+`/`/` in the payload. Rust `opaque-hurl-helper` and any other /// client parses through `decode_opaque_b64` above which accepts BOTH /// flavours, so this direction is asymmetric on purpose — URL-safe is /// the compatible superset for the client mix we support. const B64: base64::engine::general_purpose::GeneralPurpose = B64_URL_NO_PAD; use opaque_ke::{ CredentialFinalization, CredentialRequest, RegistrationRequest, RegistrationUpload, ServerLoginStartParameters, ServerRegistration, }; use rand_core::OsRng; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; use crate::application::dtos::user_dto::AuthResponseDto; use crate::common::di::AppState; use crate::infrastructure::services::opaque_login_exchange::{ExchangeId, OpaqueLoginExchange}; use crate::infrastructure::services::opaque_service::{OpaqueService, OxiCloudSuite}; use crate::interfaces::api::cookie_auth; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::CurrentUserId; /// Session-required OPAQUE routes. Callers layer the auth + CSRF /// middlewares in `main.rs` (mirrors [`auth_protected_routes`]). /// Session-required OPAQUE register routes. Mount under /// `/api/auth/opaque/register` in `main.rs` with the auth + CSRF /// layer stack (mirrors [`auth_protected_routes`]). /// /// **The mount prefix MUST be distinct from `opaque_login_routes`'s /// prefix.** Nesting both routers at the same `/api/auth` node /// causes axum's `.layer()` to cross-apply between siblings on /// shared prefixes — the login endpoints would then inherit /// register's auth+CSRF middleware and 403 every unauthenticated /// KE1. Separate prefixes side-step the composition rule cleanly. pub fn opaque_register_routes() -> Router> { Router::new() .route("/start", post(register_start)) .route("/finish", post(register_finish)) } /// Public (unauthenticated) OPAQUE login routes. Mount under /// `/api/auth/opaque/login` with the same `rate_limit_login` /// layer used on legacy `/api/auth/login` so an attacker can't /// halve the per-identity budget by spraying both endpoints. See /// [`opaque_register_routes`] on why the prefix must be distinct. /// /// `lookup` lives here (not on the params mount) so it shares the /// login rate limiter — without that, an attacker could use it as a /// cheaper user-existence probe than legacy login. Anti-enum on the /// response shape closes the primary information leak; the rate /// limit closes the secondary "how fast can I ask" leak. pub fn opaque_login_routes() -> Router> { Router::new() .route("/lookup", post(login_lookup)) .route("/ke1", post(login_ke1)) .route("/ke3", post(login_ke3)) } /// Public config-publish endpoint. Mount under `/api/auth/opaque` /// with NO rate limit — it's a static config read the SPA hits /// once at page load (cache-friendly). Kept distinct from the /// login mount to avoid layering the login rate limiter on a read /// that isn't a login attempt. pub fn opaque_params_routes() -> Router> { Router::new().route("/params", get(opaque_params)) } /// Client → server on register KE1. `registrationRequest` is the /// base64-encoded output of the client's /// `ClientRegistration::start(...).message`. #[derive(Debug, Deserialize, ToSchema)] pub struct OpaqueRegisterStartDto { #[serde(rename = "registrationRequest")] pub registration_request: String, } /// Server → client on register KE1. `registrationResponse` is the /// base64-encoded output of `ServerRegistration::start(...).message`. /// Also echoes `ciphersuiteVersion` so the client can reject a /// mid-flight suite change and abort before uploading. #[derive(Debug, Serialize, ToSchema)] pub struct OpaqueRegisterStartResponse { #[serde(rename = "registrationResponse")] pub registration_response: String, #[serde(rename = "ciphersuiteVersion")] pub ciphersuite_version: i16, } /// Client → server on register KE2. `registrationRecord` is the /// base64-encoded output of `ClientRegistration::finish(...).message`; /// `ciphersuiteVersion` is what the client believed it was minting /// under (compared to the current server value — mismatch → 400). /// /// `ksf*` fields carry the Argon2id parameters the client actually /// used at `startRegistration`/`finishRegistration` time. Server /// persists them per-envelope so future changes to the server's /// `OpaqueConfig::ksf_*` don't invalidate this envelope. Optional /// on the wire (older clients that predate per-envelope storage /// omit them; server falls back to its current config in that case). #[derive(Debug, Deserialize, ToSchema)] pub struct OpaqueRegisterFinishDto { #[serde(rename = "registrationRecord")] pub registration_record: String, #[serde(rename = "ciphersuiteVersion")] pub ciphersuite_version: i16, #[serde(rename = "ksfMemoryKib", default)] pub ksf_memory_kib: Option, #[serde(rename = "ksfIterations", default)] pub ksf_iterations: Option, #[serde(rename = "ksfParallelism", default)] pub ksf_parallelism: Option, } /// KE1 (register/start): parse client `RegistrationRequest`, run /// `ServerRegistration::start`, return the server response. /// /// No state is persisted server-side by this call — registration is /// stateless on the server between KE1 and KE2 (the `RegistrationRequest` /// carries the client's OPRF blinding; the response includes the /// server's static pubkey; the client alone knows the seed). #[utoipa::path( post, path = "/api/auth/opaque/register/start", request_body = OpaqueRegisterStartDto, responses( (status = 200, description = "Server registration response", body = OpaqueRegisterStartResponse), (status = 400, description = "Malformed registration request"), (status = 401, description = "Not authenticated"), (status = 503, description = "OPAQUE service not configured"), ), security(("bearerAuth" = [])), tag = "auth" )] pub async fn register_start( State(state): State>, CurrentUserId(user_id): CurrentUserId, Json(dto): Json, ) -> Result { let svc = require_opaque_service(&state)?; let req_bytes = decode_opaque_b64(&dto.registration_request).map_err(|e| { tracing::info!( target: "audit", event = "opaque.register_start_rejected", reason = "malformed_base64", user_id = %user_id, error = %e, "👮🏻‍♂️ OPAQUE register/start rejected: registrationRequest is not valid base64" ); malformed("registrationRequest is not valid base64") })?; let req = RegistrationRequest::::deserialize(&req_bytes).map_err(|e| { tracing::info!( target: "audit", event = "opaque.register_start_rejected", reason = "malformed_registration_request", user_id = %user_id, error = %e, "👮🏻‍♂️ OPAQUE register/start rejected: RegistrationRequest deserialize failed" ); malformed("registrationRequest failed to deserialize") })?; // `user_id` (a UUID) is the OPAQUE server-side user identifier. // Encoded as the UUID's raw bytes so the same identifier bytes // appear on both sides regardless of string representation — the // client passes the same encoding via KE1's login step (Phase 1 // login endpoints will mirror this decision). let user_bytes = user_id.as_bytes(); let result = ServerRegistration::::start(svc.setup(), req, user_bytes).map_err(|e| { tracing::warn!( target: "audit", event = "opaque.register_start_failed", reason = "server_start_error", user_id = %user_id, error = %e, "OPAQUE server registration start failed" ); AppError::bad_request("OPAQUE server registration failed") })?; let response_b64 = B64.encode(result.message.serialize()); Ok(Json(OpaqueRegisterStartResponse { registration_response: response_b64, ciphersuite_version: svc.ciphersuite_version(), })) } /// KE2 (register/finish): parse client `RegistrationUpload`, finalise /// via `ServerRegistration::finish`, persist the resulting envelope /// bytes. Idempotent-per-user: re-running finish overwrites the /// existing envelope (COALESCEing `opaque_registered_at`). #[utoipa::path( post, path = "/api/auth/opaque/register/finish", request_body = OpaqueRegisterFinishDto, responses( (status = 204, description = "Registration persisted"), (status = 400, description = "Malformed record or ciphersuite mismatch"), (status = 401, description = "Not authenticated"), (status = 503, description = "OPAQUE service not configured"), ), security(("bearerAuth" = [])), tag = "auth" )] pub async fn register_finish( State(state): State>, CurrentUserId(user_id): CurrentUserId, Json(dto): Json, ) -> Result { let svc = require_opaque_service(&state)?; let repo = require_opaque_repo(&state)?; // Ciphersuite mismatch is a hard fail — the envelope the client // is about to upload would be unusable under the server's current // suite, so persisting it would just delay the failure to login // time. Reject at KE2 with a machine-readable error_type so the // SPA can prompt a page refresh + retry. if dto.ciphersuite_version != svc.ciphersuite_version() { tracing::info!( target: "audit", event = "opaque.register_rejected", reason = "ciphersuite_mismatch", user_id = %user_id, client_version = dto.ciphersuite_version, server_version = svc.ciphersuite_version(), "👮🏻‍♂️ OPAQUE ciphersuite mismatch — client must refresh params and retry" ); return Err(AppError::new( StatusCode::BAD_REQUEST, format!( "ciphersuiteVersion mismatch: client={}, server={}", dto.ciphersuite_version, svc.ciphersuite_version() ), "OpaqueCiphersuiteMismatch", )); } let record_bytes = decode_opaque_b64(&dto.registration_record) .map_err(|_| malformed("registrationRecord is not valid base64"))?; let record = RegistrationUpload::::deserialize(&record_bytes) .map_err(|_| malformed("registrationRecord failed to deserialize"))?; // `ServerRegistration::finish` in opaque-ke 3.x is pure — it // packages the client's upload into a persistable form. No // server-side state, no side effect. We write those bytes as the // envelope; login-time `ServerLogin::start` will read them back. let stored = ServerRegistration::::finish(record); let envelope_bytes = stored.serialize(); // KSF params the client used. Client-declared per migration // 20261005000000; older clients omit and we fall back to the // server's CURRENT config values (best guess: the client fetched // /params right before registering, so current-config is what it // saw). Persisting the exact per-envelope values means future // config changes don't break this envelope on login. let ksf = crate::application::ports::opaque_ports::StoredKsf { memory_kib: dto .ksf_memory_kib .unwrap_or_else(|| svc.config_ksf_memory_kib()), iterations: dto .ksf_iterations .unwrap_or_else(|| svc.config_ksf_iterations()), parallelism: dto .ksf_parallelism .unwrap_or_else(|| svc.config_ksf_parallelism()), }; repo.write_registration(user_id, &envelope_bytes, svc.ciphersuite_version(), ksf) .await .map_err(|e| { tracing::error!( target: "audit", event = "opaque.register_persist_failed", reason = "repo_write_error", user_id = %user_id, error = %e, "OPAQUE envelope persistence failed" ); AppError::internal_error("OPAQUE envelope persistence failed") })?; tracing::info!( target: "audit", event = "opaque.register_ok", user_id = %user_id, ciphersuite_version = svc.ciphersuite_version(), envelope_bytes = envelope_bytes.len(), "OPAQUE registration persisted" ); Ok(StatusCode::NO_CONTENT) } // ── Login: lookup (Phase 3) ────────────────────────────────────────── /// Client → server on lookup. Same identifier shape as /// [`OpaqueLoginKe1Dto::user_identifier`] and legacy /// `/api/auth/login`'s `username` field: `@` in the input dispatches /// to email lookup, absence → username lookup. #[derive(Debug, Deserialize, ToSchema)] pub struct OpaqueLookupDto { #[serde(rename = "userIdentifier")] pub user_identifier: String, } /// Server → client on lookup. The SPA reads `hasOpaque` to decide /// whether to run OPAQUE login (KE1/KE3) or fall back to legacy /// `/api/auth/login` (which then silently registers via the Phase 2 /// hook). /// /// **Anti-enum invariant** — this shape MUST be identical whether /// the user exists or not: /// /// * unknown user → `hasOpaque: false` /// * user, no envelope → `hasOpaque: false` /// * user with envelope → `hasOpaque: true` /// /// A probing attacker cannot distinguish "unknown" from "known but /// unregistered" from the response body. The only signal an /// attacker gets is "known with envelope" vs "everything else," /// which reveals adoption progress but NOT identity existence. #[derive(Debug, Serialize, ToSchema)] pub struct OpaqueLookupResponse { #[serde(rename = "hasOpaque")] pub has_opaque: bool, /// KSF parameters this user's envelope was minted under. Present /// only when `has_opaque = true`. The client MUST use these values /// (not the ones from `GET /params`) on the login handshake — the /// envelope's OPRF derivation was bound to them at register time /// and a mismatch will fail the AKE with `InvalidCredentials`. /// /// `None` when: (a) `has_opaque = false` (nothing to publish), /// or (b) the envelope predates per-envelope-KSF storage /// (migration `20261005000000`) — in which case the client falls /// back to `/params` values, which is the same behaviour as /// before per-envelope storage existed. /// /// Anti-enum note: the presence of this field ONLY signals what /// `has_opaque` already signals (positive existence). Value /// differences across users could reveal timing of registration /// but not identity — same low-severity leak as the existing /// per-identifier probe, no additional exposure. #[serde(rename = "ksf", skip_serializing_if = "Option::is_none")] pub ksf: Option, } /// Resolve `userIdentifier` → envelope-existence check. Used by the /// SPA login form to branch between OPAQUE and legacy on submit. /// /// Rate-limited via the shared `login_limiter` (mount layer in /// `main.rs`), so lookup can't be used as a cheap enumeration probe. #[utoipa::path( post, path = "/api/auth/opaque/login/lookup", request_body = OpaqueLookupDto, responses( (status = 200, description = "Whether the identifier resolves to a user with an OPAQUE envelope", body = OpaqueLookupResponse), (status = 400, description = "Malformed request body"), (status = 503, description = "OPAQUE service not configured"), ), tag = "auth" )] pub async fn login_lookup( State(state): State>, Json(dto): Json, ) -> Result { let _svc = require_opaque_service(&state)?; let repo = require_opaque_repo(&state)?; let auth = require_auth_application_service(&state)?; let identifier = dto.user_identifier.trim(); if identifier.is_empty() { return Err(malformed("userIdentifier is empty")); } // Resolve the identifier → user_id → envelope presence + KSF. // Any miss (unknown user, user without envelope, DB blip) collapses // to `hasOpaque: false, ksf: None` — the anti-enum contract on the // wire shape. No audit event here: a successful lookup isn't a // login attempt, and logging every miss would flood the channel // without adding signal (rate limiter already caps volume; // enumeration attempts show up in the login-lockout / rate-limit // metrics). // // KSF fallback: if the envelope has NULL KSF (predates per-envelope // storage migration 20261005000000), we return `ksf: None` — the // client then uses the server's current `/params` values, which is // the pre-per-envelope-storage behaviour. let (has_opaque, ksf) = match auth.lookup_user_for_login(identifier).await { Ok(user) => match repo.read_registration(user.id()).await { Ok(Some(stored)) => { let ksf = stored.ksf.map(|k| OpaqueKsfParams { memory_kib: k.memory_kib, iterations: k.iterations, parallelism: k.parallelism, }); (true, ksf) } _ => (false, None), }, Err(_) => (false, None), }; Ok(Json(OpaqueLookupResponse { has_opaque, ksf })) } // ── Login: KE1 + KE3 ───────────────────────────────────────────────── /// Client → server on KE1. `userIdentifier` is the same string the /// SPA sends to legacy `/api/auth/login` — email if it contains `@`, /// username otherwise (server dispatches on `@`, same as legacy). /// `startLoginRequest` is the base64-encoded output of /// `ClientLogin::start(...).message`. #[derive(Debug, Deserialize, ToSchema)] pub struct OpaqueLoginKe1Dto { #[serde(rename = "userIdentifier")] pub user_identifier: String, #[serde(rename = "startLoginRequest")] pub start_login_request: String, } /// Server → client on KE1. `exchangeId` is an opaque handle the /// client MUST echo back on KE3 (single-use, 60 s TTL); the client /// can't derive server state from it. `loginResponse` is the /// base64-encoded output of `ServerLogin::start(...).message`. #[derive(Debug, Serialize, ToSchema)] pub struct OpaqueLoginKe1Response { #[serde(rename = "exchangeId")] #[schema(value_type = String, format = "uuid")] pub exchange_id: ExchangeId, #[serde(rename = "loginResponse")] pub login_response: String, } /// Client → server on KE3. `exchangeId` matches what KE1 handed /// back; `finishLoginRequest` is the base64-encoded output of /// `ClientLogin::finish(...).message`. #[derive(Debug, Deserialize, ToSchema)] pub struct OpaqueLoginKe3Dto { #[serde(rename = "exchangeId")] #[schema(value_type = String, format = "uuid")] 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, } /// KE1: user lookup → envelope fetch → `ServerLogin::start` → stash /// state under a fresh exchange_id → return handle + response bytes. /// /// ## Anti-enumeration /// /// A KE1 that CAN'T be honestly answered (unknown user, or user /// exists but has no OPAQUE envelope yet) MUST look identical to a /// KE1 that CAN. opaque-ke's `ServerLogin::start` supports a "dummy" /// branch (`password_file = None`) that generates a well-formed /// KE2 response indistinguishable from the real branch. The /// dummy-branch KE3 will fail at the client-side `ClientLogin::finish` /// (wrong MAC), never reaching the server — so KE3 for the /// non-existent user is symmetric with KE3 for a wrong passphrase. /// /// User is NOT looked up via `_with_perms` — this is the auth /// bootstrap; there's no caller identity yet. We use the same /// dispatch as legacy `/api/auth/login`. #[utoipa::path( post, path = "/api/auth/opaque/login/ke1", request_body = OpaqueLoginKe1Dto, responses( (status = 200, description = "Login response + single-use exchange handle", body = OpaqueLoginKe1Response), (status = 400, description = "Malformed request payload"), (status = 503, description = "OPAQUE service not configured"), ), tag = "auth" )] pub async fn login_ke1( State(state): State>, Json(dto): Json, ) -> Result { let svc = require_opaque_service(&state)?; let repo = require_opaque_repo(&state)?; let exchange = require_opaque_exchange(&state)?; let cred_bytes = decode_opaque_b64(&dto.start_login_request) .map_err(|_| malformed("startLoginRequest is not valid base64"))?; let cred_request = CredentialRequest::::deserialize(&cred_bytes) .map_err(|_| malformed("startLoginRequest failed to deserialize"))?; // Resolve identifier → user_id, then fetch the envelope. Both // steps can fail (unknown user, no envelope) — collapse into a // single Option so `ServerLogin::start` sees the anti-enum // shape cleanly. let (user_bytes, password_file) = resolve_user_and_envelope(&state, &dto.user_identifier) .await .unwrap_or_else(|| (dto.user_identifier.as_bytes().to_vec(), None)); // `ServerLogin::start(..., Some(file), ...)` runs the real // handshake; `None` runs the dummy branch that still produces a // well-formed KE2 tied to the same suite so a probing attacker // can't distinguish the two paths from response shape or timing // (opaque-ke pads the dummy branch to match). let mut server_rng = OsRng; let started = opaque_ke::ServerLogin::start( &mut server_rng, svc.setup(), password_file, cred_request, &user_bytes, ServerLoginStartParameters::default(), ) .map_err(|e| { // A start error at this stage is a genuine protocol failure // (bad KE1 payload, ciphersuite mismatch in the wire bytes). // We STILL respond 400 rather than 200-with-dummy — 200 // response would let the attacker distinguish "protocol // error" from "unknown user"; the caller getting a 400 has // to try a different KE1 anyway. tracing::info!( target: "audit", event = "opaque.login_ke1_rejected", reason = "server_start_error", attempted_identifier = %dto.user_identifier, error = %e, "👮🏻‍♂️ OPAQUE KE1 rejected: protocol error" ); AppError::new( StatusCode::BAD_REQUEST, "OPAQUE login start failed", "OpaqueMalformedRequest", ) })?; // Silence unused-warning for repo when the branch above returns // None — repo IS used inside `resolve_user_and_envelope` (via // `state.opaque_repo`) but the closure hides that from the // compiler's flow analysis. let _ = &repo; // Stash user_id alongside ServerLogin so KE3 knows which account // just proved possession of the passphrase without having to // re-parse the AKE payload (opaque-ke doesn't hand the identifier // back on `finish` — it's checked implicitly against the KE1 // state's expected identifier). let user_id = user_id_from_bytes(&user_bytes); let response_b64 = B64.encode(started.message.serialize()); let exchange_id = exchange.store(started.state, user_id); Ok(Json(OpaqueLoginKe1Response { exchange_id, login_response: response_b64, })) } /// KE3: consume exchange state → `ServerLogin::finish` → mint session. /// /// On success: /// /// * Stamp `opaque_migrated_at` (Phase 3 signal that this user /// has completed at least one OPAQUE login — future legacy /// login attempts will be refused once Phase 4 flips to /// `opaque_only`). /// * Mint access + refresh tokens under a fresh session family, /// via `AuthApplicationService::mint_session_for_authenticated_user`. /// * Return the shared `AuthResponseDto` shape identical to /// `POST /api/auth/login` — the SPA consumes both paths through /// one downstream handler. /// /// On failure (bad passphrase, replay, expired exchange, unknown /// exchange_id): 401 `InvalidCredentials` — the SAME shape for every /// failure branch so attackers can't distinguish "expired" from /// "wrong password" from "id already consumed". #[utoipa::path( post, path = "/api/auth/opaque/login/ke3", request_body = OpaqueLoginKe3Dto, responses( (status = 200, description = "Session issued", body = AuthResponseDto), (status = 400, description = "Malformed request payload"), (status = 401, description = "Invalid credentials"), (status = 503, description = "OPAQUE service not configured"), ), tag = "auth" )] pub async fn login_ke3( State(state): State>, axum::extract::ConnectInfo(peer): axum::extract::ConnectInfo, headers: axum::http::HeaderMap, Json(dto): Json, ) -> Result { let _svc = require_opaque_service(&state)?; let repo = require_opaque_repo(&state)?; let exchange = require_opaque_exchange(&state)?; let auth = require_auth_application_service(&state)?; // Atomic take FIRST — before touching the payload. Two reasons: // // 1. Anti-enum: an unknown / expired / already-consumed // exchange_id returns 401 `InvalidCredentials` regardless // of payload shape, matching the wrong-passphrase case // exactly. If we parsed the payload first, a malformed // body would 400 EVEN for an unknown id — leaking the fact // that some KE3 shapes are valid vs invalid. // 2. Anti-replay: consuming the exchange first guarantees the // handle is single-use even if the caller sends garbage // afterwards — an attacker with a stolen id can't spam // "try shapes until one parses" against the same handle. let stash = exchange.take(dto.exchange_id).ok_or_else(|| { tracing::info!( target: "audit", event = "opaque.login_ke3_rejected", reason = "unknown_or_expired_exchange", exchange_id = %dto.exchange_id, "👮🏻‍♂️ OPAQUE KE3 rejected: unknown/expired/replayed exchange_id" ); invalid_credentials() })?; let cred_bytes = decode_opaque_b64(&dto.finish_login_request) .map_err(|_| malformed("finishLoginRequest is not valid base64"))?; let cred_final = CredentialFinalization::::deserialize(&cred_bytes) .map_err(|_| malformed("finishLoginRequest failed to deserialize"))?; // If the AKE integrity check fails (wrong passphrase, dummy-branch // KE1 for an unknown user, tampered bytes), `finish` errors and // we return the same 401 shape as an unknown exchange_id above. let _finished = stash.state.finish(cred_final).map_err(|e| { tracing::info!( target: "audit", event = "opaque.login_ke3_rejected", reason = "ake_check_failed", exchange_id = %dto.exchange_id, error = %e, "👮🏻‍♂️ OPAQUE KE3 rejected: AKE / passphrase check failed" ); invalid_credentials() })?; // Both sides now agree on a shared session_key. The current // implementation does NOT derive the bearer token from it — // reusing the existing token minter keeps the session shape // identical to legacy login. Cryptographically tying the // access_token to `session_key` via HKDF is a follow-up (see // docs/plan/opaque.md §Step 5 notes). // // The user_id comes from the KE1-side stash (resolved from the // client's identifier before the dummy-vs-real branch), not from // the AKE payload. Anti-enum: the dummy branch has // `stash.user_id = None`; a dummy KE3 that somehow reached this // point (should not — it fails at `finish` above) returns the // same InvalidCredentials shape. let user_id = stash.user_id.ok_or_else(invalid_credentials)?; // Fetch the user entity — needed by mint_session_for_authenticated_user // (it calls dispatch_login + register_login + generates tokens // from the user's role/email/etc.). let user = auth.get_user_entity(user_id).await.map_err(|_| { // User row vanished between KE1's envelope fetch and now // (delete race). Same shape as bad passphrase — never // leak "you passed the crypto but the account is gone." tracing::warn!( target: "audit", event = "opaque.login_ke3_rejected", reason = "user_gone_after_ke3", user_id = %user_id, "👮🏻‍♂️ OPAQUE KE3: user disappeared between KE1 and KE3" ); invalid_credentials() })?; // Capture client IP + User-Agent so `sessions.ip_address` / // `user_agent` land populated instead of NULL (admin panel would // otherwise render "—"). Both are per-session and only refresh // on rotation, matching the login pattern. let client_ip = crate::interfaces::middleware::trusted_proxy::client_ip_from_parts( &headers, Some(peer), false, ); let user_agent = headers .get(axum::http::header::USER_AGENT) .and_then(|v| v.to_str().ok()) .map(str::to_owned); // Mint the session BEFORE stamping opaque_migrated_at — if the // session mint fails (rare, but not impossible under DB failure), // 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, dto.dpop_jkt, Some(client_ip), user_agent, crate::domain::entities::session::SessionOrigin::Opaque, ) .await .map_err(AppError::from)?; if let Err(e) = repo.mark_migrated(user_id).await { // Non-fatal: session is already issued, user is logged in. // Log at warn so ops sees any consistent trend, but don't // fail the response — the mark is idempotent so a later // login will retry. tracing::warn!( target: "audit", event = "opaque.mark_migrated_deferred", user_id = %user_id, error = %e, "OPAQUE login succeeded but mark_migrated failed — will retry on next login" ); } tracing::info!( target: "audit", event = "opaque.login_ok", user_id = %user_id, "OPAQUE login completed" ); // Mint the HttpOnly auth cookies + double-submit CSRF cookie — // the SAME shape the legacy `/api/auth/login` handler emits. The // SPA reads the CSRF cookie into an `X-CSRF-Token` header on every // mutating request, and gates its "login succeeded" branch on the // cookie being present (`csrfCookiePresent()` in the login page). // Returning just `Json(session)` without touching cookies — the // shape this handler used before — silently broke the browser // login flow: session tokens were valid but no cookies landed, so // the SPA reported "Login succeeded but the browser rejected the // session cookie" even on http+cookie_secure=false deployments. let mut response = (StatusCode::OK, Json(&session)).into_response(); cookie_auth::append_auth_cookies( response.headers_mut(), &session.access_token, &session.refresh_token, session.expires_in, state.core.config.auth.refresh_token_expiry_secs, ); cookie_auth::append_csrf_cookie(response.headers_mut(), session.expires_in); // Seed the SPA's DPoP-nonce cache so the first bound request after // login doesn't eat a `use_dpop_nonce` challenge → retry cycle. // No-op when `dpop_mode = off`. cookie_auth::maybe_append_dpop_nonce_cookie( response.headers_mut(), &state.dpop_nonce_service, state.core.config.auth.dpop_mode, ); Ok(response) } // ── Params publish ─────────────────────────────────────────────────── /// Client-side Argon2id parameters the SPA must feed to /// `@serenity-kit/opaque` on `finishRegistration` / `finishLogin`. /// Values MUST match the server's `OpaqueConfig::ksf_*` — the /// handshake fails to derive matching keys otherwise, so publishing /// these is what keeps client and server in lock-step across param /// bumps. #[derive(Debug, Serialize, ToSchema)] pub struct OpaqueKsfParams { #[serde(rename = "memoryKib")] pub memory_kib: u32, pub iterations: u32, pub parallelism: u32, } /// Payload of `GET /api/auth/opaque/params`. `enabled = false` when /// the OPAQUE substrate is not wired for this deployment (mode=off, /// or password auth disabled — the same cross-check /// `OpaqueConfig::effective_mode` runs). SPA gates the OPAQUE code /// path on this flag; when false, it falls back to legacy password /// auth as if OPAQUE didn't exist. /// /// `ciphersuiteVersion` + `ksf` are ALWAYS populated (safe defaults /// even when `enabled = false`) so a client that ignored `enabled` /// wouldn't nil-deref. #[derive(Debug, Serialize, ToSchema)] pub struct OpaqueParamsResponse { pub enabled: bool, #[serde(rename = "ciphersuiteVersion")] pub ciphersuite_version: i16, pub ksf: OpaqueKsfParams, } /// Publish the OPAQUE client config. Safe to call unauthenticated /// (nothing about individual users is returned) and cache-friendly /// (the response only changes when the operator rotates env vars). /// /// Note: `Cache-Control` is deliberately unset — the SPA fetches /// this once at page load, and if the operator rotates the KSF /// params mid-flight, we want the change to be picked up on the /// next SPA reload rather than lingering behind an intermediary /// cache. #[utoipa::path( get, path = "/api/auth/opaque/params", responses( (status = 200, description = "OPAQUE client config", body = OpaqueParamsResponse), ), tag = "auth" )] pub async fn opaque_params(State(state): State>) -> impl IntoResponse { // Reads from OpaqueService when substrate is wired; falls back // to the OpaqueConfig defaults otherwise so an // `enabled=false` payload still has plausible-shape numeric // fields (the SPA logic just short-circuits on the flag). let (enabled, ciphersuite_version, ksf_memory_kib, ksf_iterations, ksf_parallelism) = match state.opaque_service.as_ref() { Some(svc) => ( true, svc.ciphersuite_version(), svc.config_ksf_memory_kib(), svc.config_ksf_iterations(), svc.config_ksf_parallelism(), ), None => { // Substrate off — publish safe defaults matching // `OpaqueConfig::default()` so a curious client can // still parse the payload cleanly. let cfg = crate::common::config::OpaqueConfig::default(); ( false, cfg.ciphersuite_version, cfg.ksf_memory_kib, cfg.ksf_iterations, cfg.ksf_parallelism, ) } }; Json(OpaqueParamsResponse { enabled, ciphersuite_version, ksf: OpaqueKsfParams { memory_kib: ksf_memory_kib, iterations: ksf_iterations, parallelism: ksf_parallelism, }, }) } // ── Small helpers ──────────────────────────────────────────────────── fn require_opaque_exchange(state: &Arc) -> Result, AppError> { state.opaque_login_exchange.clone().ok_or_else(|| { AppError::new( StatusCode::SERVICE_UNAVAILABLE, "OPAQUE login-exchange cache is not wired", "OpaqueDisabled", ) }) } fn require_auth_application_service( state: &Arc, ) -> Result< Arc, AppError, > { Ok(state .auth_service .as_ref() .ok_or_else(|| AppError::internal_error("Authentication service not configured"))? .auth_application_service .clone()) } /// Resolve KE1's `userIdentifier` to the OPAQUE server-side user /// identifier + the stored envelope. Returns `None` (silently) /// when either the user doesn't exist or has no envelope on file — /// both branches converge to the anti-enum dummy-KE1 path. /// /// The tuple's first element (`Vec`) is what we pass to /// `ServerLogin::start` as the OPAQUE user identifier. For real users /// we use `user_id.as_bytes()` (matches the register handler); for /// the unknown-user branch we hash the CLAIMED identifier so the /// dummy branch's identifier bytes are still deterministic /// per-attempt (opaque-ke uses this in the AKE derivation). async fn resolve_user_and_envelope( state: &Arc, identifier: &str, ) -> Option<(Vec, Option>)> { let auth = state.auth_service.as_ref()?; let repo = state.opaque_repo.as_ref()?; let user = auth .auth_application_service .lookup_user_for_login(identifier) .await .ok()?; let stored = repo.read_registration(user.id()).await.ok().flatten(); let password_file = stored.and_then(|s| ServerRegistration::::deserialize(&s.envelope).ok()); Some((user.id().as_bytes().to_vec(), password_file)) } /// Recover a UUID from the OPAQUE user identifier bytes IF they're /// the 16-byte shape written by `resolve_user_and_envelope`. For the /// dummy branch (identifier = raw caller-supplied string), the bytes /// are almost never 16 long, so this returns `None` — that's the /// correct signal to KE1's callers ("don't stamp a user_id in the /// stash for the dummy branch"). fn user_id_from_bytes(bytes: &[u8]) -> Option { let arr: [u8; 16] = bytes.try_into().ok()?; Some(Uuid::from_bytes(arr)) } fn invalid_credentials() -> AppError { AppError::new( StatusCode::UNAUTHORIZED, "Invalid credentials", "InvalidCredentials", ) } fn require_opaque_service(state: &Arc) -> Result, AppError> { state.opaque_service.clone().ok_or_else(|| { AppError::new( StatusCode::SERVICE_UNAVAILABLE, "OPAQUE is not enabled on this server", "OpaqueDisabled", ) }) } fn require_opaque_repo( state: &Arc, ) -> Result, AppError> { state.opaque_repo.clone().ok_or_else(|| { AppError::new( StatusCode::SERVICE_UNAVAILABLE, "OPAQUE persistence is not wired", "OpaqueDisabled", ) }) } fn malformed(msg: &'static str) -> AppError { AppError::new(StatusCode::BAD_REQUEST, msg, "OpaqueMalformedRequest") } // Discourage silent conversions of the `Uuid` extractor into `_` — // forces future callers to acknowledge it. #[allow(dead_code)] fn _uuid_extractor_marker(u: Uuid) -> Uuid { u }