feat(opaque): wire API

- POST /api/auth/opaque/login/ke1 (public) — takes {userIdentifier, startLoginRequest}, resolves the identifier via the same @-dispatch as legacy login (AuthApplicationService::lookup_user_for_login, factored out), fetches envelope, runs ServerLogin::start (real branch for known users, dummy branch for anti-enum on unknown/unregistered), stashes state under a random exchange_id in the moka cache, returns {exchangeId, loginResponse}.
- POST /api/auth/opaque/login/ke3 (public) — atomic take from the cache FIRST (anti-enum + anti-replay), then decodes the payload, runs ServerLogin::finish, stamps opaque_migrated_at (Phase 3 signal), and mints a session via the new AuthApplicationService::mint_session_for_authenticated_user helper — returns the same AuthResponseDto shape as legacy login so the SPA has one downstream handler.
- Session mint factored: mint_session_for_authenticated_user(User) extracted from login() so both the legacy password path and OPAQUE KE3 converge through one implementation.
- OpaqueRepositoryPort::mark_migrated with COALESCE-preserving idempotent stamp of opaque_migrated_at.
- opaque-setup CLI + Dockerfile wiring already shipped (Step 0 hygiene).
- Routing fix: sub-prefix split (/api/auth/opaque/register vs /api/auth/opaque/login) — axum composes middleware between sibling nests at the same prefix, which was cross-applying auth+CSRF to my public login routes. Distinct prefixes side-step that cleanly. Documented in both main.rs and the router builder doc.
- Rate-limit sharing: login KE1/KE3 layered with the SAME login_limiter instance as legacy POST /api/auth/login, so an attacker can't halve the per-IP budget by spraying both endpoints.

Anti-enum + anti-replay hardening in KE3: take runs BEFORE payload parse so:
- Unknown / expired / already-consumed exchange_id → 401 InvalidCredentials (same shape as wrong-passphrase, no payload-shape leak)
- Consumed handle can't be re-used to spam parse attempts
This commit is contained in:
Edouard Vanbelle
2026-07-27 22:23:15 +02:00
parent ae65c8475f
commit 7d7621e387
7 changed files with 696 additions and 49 deletions
+27 -8
View File
@@ -790,11 +790,16 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
auth_middleware,
))
.with_state(app_state.clone());
// OPAQUE register routes — require auth + CSRF. The handlers
// return 503 `OpaqueDisabled` when the substrate isn't wired
// (mode=off or password auth disabled), so mounting them
// unconditionally is safe: the mode gate lives in the DI
// factory, not the router.
// OPAQUE aPAKE routes — nested under DISTINCT sub-prefixes
// so axum doesn't cross-apply middleware between the two
// branches (`.nest("/api/auth", A).nest("/api/auth", B)`
// composes their layers on shared prefixes; distinct
// prefixes avoid that entirely).
//
// Handlers return 503 `OpaqueDisabled` when the substrate
// isn't wired (mode=off / password auth disabled); the mode
// gate lives in the DI factory, so mounting unconditionally
// is safe.
let opaque_register_protected =
oxicloud::interfaces::api::handlers::opaque_auth_handler::opaque_register_routes()
.layer(axum::middleware::from_fn(csrf_middleware))
@@ -803,6 +808,13 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
auth_middleware,
))
.with_state(app_state.clone());
let opaque_login_public =
oxicloud::interfaces::api::handlers::opaque_auth_handler::opaque_login_routes()
.layer(axum::middleware::from_fn_with_state(
login_limiter.clone(),
rate_limit_login,
))
.with_state(app_state.clone());
// One-time setup route — public, rate-limited like register
let setup_router = setup_route()
.layer(axum::middleware::from_fn_with_state(
@@ -909,12 +921,19 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
"/api/auth",
app_pw_protected.layer(access_log!("http::api::auth")),
)
// OPAQUE aPAKE — session-required register endpoints. Login
// endpoints (public) are mounted in a later Phase 1 step.
// OPAQUE aPAKE — session-required register endpoints
// (mounted under a distinct sub-prefix so auth+CSRF
// don't bleed into the sibling login mount).
.nest(
"/api/auth",
"/api/auth/opaque/register",
opaque_register_protected.layer(access_log!("http::api::auth")),
)
// OPAQUE aPAKE — public login endpoints (KE1 + KE3).
// Rate-limit shared with legacy login above.
.nest(
"/api/auth/opaque/login",
opaque_login_public.layer(access_log!("http::api::auth")),
)
// One-time setup endpoint — public, rate-limited
.nest("/api", setup_router.layer(access_log!("http::api")))
// Device Auth Grant public endpoints (authorize + token polling)