From 7fc68c50d5f629e2a2cddfa2b78479f90d3c56c2 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sat, 8 Aug 2026 12:55:13 +0200 Subject: [PATCH 01/51] feat(DPoP): add schema & session & PG repos --- docs/plan/dpop.md | 292 ++++++++++++++++++ .../20261008000000_sessions_dpop_jkt.sql | 34 ++ src/domain/entities/session.rs | 86 ++++++ .../repositories/pg/session_pg_repository.rs | 19 +- 4 files changed, 424 insertions(+), 7 deletions(-) create mode 100644 docs/plan/dpop.md create mode 100644 migrations/20261008000000_sessions_dpop_jkt.sql diff --git a/docs/plan/dpop.md b/docs/plan/dpop.md new file mode 100644 index 00000000..4b2ae7bb --- /dev/null +++ b/docs/plan/dpop.md @@ -0,0 +1,292 @@ +# DPoP (Demonstrating Proof-of-Possession) implementation plan + +**Status**: draft — not yet started. +**Companion**: `docs/plan/opaque-only.md` (OPAQUE is orthogonal but complementary — OPAQUE authenticates the user, DPoP binds the resulting session to a specific browser). + +## Motivation + +OxiCloud's current session-binding stack: + +1. `HttpOnly` cookies (JS can't read the token) +2. `SameSite=Strict` (cross-site can't send the cookie) +3. `Secure` (HTTPS-only in production) +4. CSRF double-submit (`X-CSRF-Token` header echoing a same-origin cookie) +5. `Session.user_agent` recorded (for the sessions-list UI — **not verified per request**) + +This stack defends well against XSS-exfiltration and cross-site forgery. It does **not** defend against **local host compromise** — an info-stealer that reads Chrome's `Cookies` SQLite DB via the OS keyring (DPAPI on Windows, Keychain on macOS) walks away with a working session that replays from anywhere. This is the dominant threat for a self-hosted cloud used from mixed-trust endpoints. + +DPoP (RFC 9449) closes this gap by binding the session to a browser-held private key. Every request carries a JWT signed with that key. Stealing the cookie without the private key gets you nothing. + +## Design decisions locked upfront + +**Scope**: **DPoP-lite** for OxiCloud's own session cookies (OPAQUE, OIDC, magic-link, admin-password). NOT full RFC 9449 resource-server mode or OIDC-bearer binding — those are Phase 2 (deferred, see end). + +**Crypto**: **ES256** (ECDSA P-256 + SHA-256). Universal `SubtleCrypto` support, RFC 9449 mandatory-to-support, ~90-byte public keys, ~50µs verify on modern CPUs. + +**Anti-replay + clock-independence**: **DPoP-Nonce** (RFC 9449 §8). Server issues an opaque nonce in a response header; client MUST include it as a `nonce` claim in subsequent proofs. Server rotates the nonce every few minutes. Because the nonce is server-generated with a server-known issue time, **the client clock never appears in the trust chain** — no ±30s skew tolerance needed, no dead-mobile-clock failures. + +**Storage**: +- **Browser**: single `IndexedDB` entry per origin holding a `CryptoKey` created with `extractable: false`. JS can call `sign()` on the handle but never `exportKey()`. The raw bytes live in the browser's crypto subsystem, at rest encrypted by the browser's per-profile key store. +- **Server**: `auth.sessions` gains a nullable `dpop_jkt VARCHAR(64)` column holding the JWK thumbprint (RFC 7638, base64url-encoded SHA-256 of the canonical JWK). + +**Threat targets**: + +| Attack | Result under DPoP | +|---|---| +| Info-stealer copies cookies to attacker's machine | ✅ Attacker has cookie but no private key → 401 on first request | +| DB backup leaked / dumped | ✅ Attacker gets thumbprints (public), useless for forgery | +| XSS reads `document.cookie` | Already blocked by `HttpOnly` — DPoP neither helps nor hurts | +| Same-origin XSS calls the fetch interceptor | ⚠️ Attacker's script signs its own requests through the interceptor. Mitigation is CSP hardening, not DPoP | +| Browser process compromised at login time | ❌ Attacker enrolls their own keypair. Only WebAuthn-attested keys close this — out of scope | +| Malicious extension with `webRequest` permission | ❌ Extension can wrap `fetch`. Same as above; browser-trust is a prerequisite | + +**Feature flag**: `OXICLOUD_DPOP_MODE ∈ {off, opportunistic, required}` +- `off` (default in dev): middleware pass-through, client sends nothing. +- `opportunistic` (staged rollout): if proof present → verify; if absent → allow. Server logs `dpop.header_missing_but_session_bound` when a bound session skips a proof, so operators can spot broken clients before flipping to `required`. +- `required` (final): sessions with `dpop_jkt IS NOT NULL` MUST present a valid proof. Sessions with `dpop_jkt IS NULL` (app passwords, legacy pre-DPoP sessions) remain exempt. + +**Non-goals for Phase 1**: +- Native Nextcloud sync clients (mobile/desktop) — Basic Auth over app passwords, no `SubtleCrypto`. Exempt via `dpop_jkt IS NULL`. +- OIDC bearer tokens minted by an upstream IdP — separate downstream problem, needs IdP cooperation. +- Multi-device attested keys via WebAuthn — major UX shift. + +--- + +## Gate 0 — Design record + +- This document, plus a lightweight `docs/adr/dpop-crypto-choice.md` pinning ES256 + DPoP-Nonce + `htu` canonicalisation rules. +- No code. +- **Deliverable**: PR-reviewable design doc. + +## Gate 1 — Schema + DTO plumbing + +- Migration `_dpop_session_binding.sql`: `ALTER TABLE auth.sessions ADD COLUMN dpop_jkt VARCHAR(64)` (nullable). +- Extend `Session` domain entity: `dpop_jkt: Option`. +- Extend `SessionRepository::create_session` signature + PG implementation (append column to `INSERT`, expose `Option<&str>` in the trait). +- No middleware, no verification, no client changes. +- **Test**: existing session tests unchanged (thumbprint stays `None`); a new test writes a fake thumbprint and reads it back. +- **Rollback**: `ALTER TABLE ... DROP COLUMN dpop_jkt` — nothing depends on it yet. + +## Gate 2 — Client keypair lifecycle + +- New module `frontend/src/lib/auth/dpop.ts`: + - `ensureKeypair(): Promise` — read from IndexedDB (`db: "oxicloud-dpop"`, store: `"keypair"`), else generate P-256 with `extractable: false`, persist, return. + - `computeJkt(pubKey: CryptoKey): Promise` — export public key JWK, canonicalise (RFC 7638), SHA-256, base64url — the thumbprint. + - `clearKeypair(): Promise` — deletes the IndexedDB entry; called from logout. + - Concurrency: wrap the ensure path in `navigator.locks.request("dpop-keypair", ...)` so two tabs opened simultaneously don't race to generate two keypairs. +- Zero server changes in this gate — keypair exists only in the browser. +- **Test**: Vitest with `fake-indexeddb`, verify that a second `ensureKeypair()` call in the same session returns the SAME `CryptoKey` handle (identity), and that the JWK thumbprint is stable across page reloads. + +## Gate 3 — Bind ceremony on login + +- Fold the thumbprint into the login request body (single round trip, no separate bind endpoint): + - OPAQUE `POST /api/auth/opaque/login/ke3`: DTO gains `dpop_jkt: Option`. + - OIDC callback `GET/POST /api/auth/oidc/callback`: harder — the browser redirects through the IdP, so the thumbprint has to survive the redirect. Two options: + - (a) Include it in the `state` param (base64url-encoded JSON, signed). + - (b) Post-callback: server issues a temporary "unbound" session; client immediately calls `POST /api/auth/dpop/bind` with the thumbprint and gets a bound cookie back. + - **Pick (b)** — simpler, doesn't inflate the `state` param, keeps OIDC parity across IdPs. Adds one round trip to OIDC login only. + - Magic-link exchange `POST /api/auth/magic-link/redeem`: DTO gains `dpop_jkt: Option`. + - Legacy password `POST /api/auth/login`: DTO gains `dpop_jkt: Option`. +- SPA changes: each `login*()` helper in `frontend/src/lib/api/endpoints/auth.ts` calls `ensureKeypair()` + `computeJkt()` before dispatch, threads the thumbprint into the body. +- Server: validate JKT is well-formed (43 chars, base64url); persist as `session.dpop_jkt`. Field ABSENCE is not a rejection at this gate — only Gate 5 in `required` mode enforces presence. +- **Test**: Hurl scenario per login path — POST with a fake JKT, then `SELECT dpop_jkt FROM auth.sessions WHERE id = ` returns exactly the sent value. +- **Rollback**: server ignores the extra field, client stops sending it. No lingering state. + +## Gate 4 — Fetch interceptor emits DPoP header (nonce-aware) + +- Extend `frontend/src/lib/api/client.ts::apiFetch`: + - Before each request, load the keypair via `ensureKeypair()`. + - Build the DPoP JWT: + - **Header**: `{typ: "dpop+jwt", alg: "ES256", jwk: }` + - **Claims**: `{htm: , htu: , iat: , jti: , nonce: }` + - Sign with `crypto.subtle.sign({name: "ECDSA", hash: "SHA-256"}, privateKey, payload)`. + - Set `DPoP: ` header. +- **Nonce handling**: + - Maintain a per-origin `currentNonce: string | null` in a module-level state (mirror in `sessionStorage` so cross-tab reads work). + - Every response with a `DPoP-Nonce` header updates `currentNonce`. + - On any 401 with `WWW-Authenticate: DPoP error="use_dpop_nonce"`, extract the fresh nonce from `DPoP-Nonce`, update `currentNonce`, retry the ORIGINAL request ONCE with the new nonce. Reject the response if the retry also fails. + - First request in a fresh browser has `currentNonce = null` → server issues a challenge → client retries with nonce → done. One extra round trip per session bootstrap. +- **No server-side verification yet** in this gate — server logs when it sees the header for observability. Nonce issuance lives in Gate 5. +- **Test**: Vitest with a real ES256 keypair — verify the emitted JWT parses, signature validates, `htu`/`htm` match, `jti` is unique per request, `nonce` is included when set. + +## Gate 5 — Server-side verifier + middleware (opportunistic mode) + +- New `src/infrastructure/services/dpop_verifier.rs`: + - Parse the `DPoP` header as JWS compact serialization (3 base64url segments). + - Extract the JWK from the JWS header. Reject if `alg != "ES256"`, `typ != "dpop+jwt"`, `jwk.kty != "EC"`, `jwk.crv != "P-256"`. + - Verify the JWS signature using the embedded public key (`p256::ecdsa::Signature::from_der(...).verify(...)` — see Gate 5 dependency note). + - Validate claims: + - `htm` == request method + - `htu` == canonical URL (`scheme://authority/path`, no query, external scheme/host from `X-Forwarded-*` if behind a proxy — mirror the same helper the request-span uses for `client_ip`) + - `iat` — informational only when nonce present; when nonce absent (very first request), fall back to ±30s tolerance + - `nonce` — validated by the nonce service (Gate 5b) + - `jti` — replay-cache lookup (Gate 6), scoped to nonce + - Compute JWK thumbprint (RFC 7638), compare to `session.dpop_jkt`. Mismatch → 401 with `reason = "jkt_mismatch"`. +- New middleware `require_dpop_layer` in `src/interfaces/middleware/dpop.rs`: + - Reads `Arc` for the verifier + nonce service + mode flag. + - Behaviour by mode: + - `off` → pass through. + - `opportunistic` → if header present, verify (401 on failure); if absent, pass. If `session.dpop_jkt IS NOT NULL` but header absent → log `dpop.header_missing_but_session_bound`. + - `required` → header MUST be present and verify, OR `session.dpop_jkt IS NULL` (exempt). + - Response shape on failure: 401 with `WWW-Authenticate: DPoP error=""` and `{error_type: "DpopVerificationFailed"}`. +- Mount on the same `/api/*` subtrees as `require_no_password_change_pending_layer`. +- Exempt paths (allowlist, not on wildcard): `/api/auth/login/*`, `/api/auth/opaque/register/*`, `/api/auth/oidc/callback`, `/api/auth/dpop/bind` (Gate 3 endpoint), and public discovery endpoints. +- **Test**: Rust unit tests for the verifier (happy path, wrong-alg, wrong-htm, wrong-htu, expired-iat when nonce absent, mismatched-jkt, malformed-JWS). Hurl scenarios for each mode. +- **Dependency check**: confirm the `jsonwebtoken` crate supports ES256 with an embedded JWK in the header. If not, use `p256` + `base64` + `serde_json` and hand-parse the compact serialization (~50 LoC, more control, no dep surprise). + +## Gate 5b — DPoP-Nonce service + +- New `src/infrastructure/services/dpop_nonce_service.rs`: + - **Nonce format**: 32 random bytes, base64url-encoded (~43 chars). + - **Store**: moka `Cache` (in-memory, per-instance). No PG persistence — nonces are ephemeral by design; on server restart clients fetch a new one via the challenge flow. + - **Lifetime**: 5 minutes rolling window (`max_time_to_live = 5min`). Store `issued_at`. + - **Reuse policy**: nonces are REUSABLE within their lifetime — one round trip per session bootstrap, not one per request. Replay protection is per-`jti` within a nonce (Gate 6). + - **Rotation**: on any response, if the current session's nonce is older than 2 minutes, issue a fresh nonce via `DPoP-Nonce` response header. Client's fetch interceptor picks it up automatically. This gives an overlap window (client's cached nonce is still valid for 3 more minutes while it starts using the fresh one) so requests in flight during rotation don't fail. + - **Challenge on missing/stale**: if `verify()` finds `nonce` claim absent OR not in the store OR expired → return 401 + `WWW-Authenticate: DPoP error="use_dpop_nonce"` + `DPoP-Nonce: ` response header. Client's Gate 4 retry logic handles the round trip. +- Cap the cache size (moka LRU max 100k entries by default) to bound memory under attack. +- **Test**: Rust unit test — issue nonce, verify accepts within window, rejects after expiry; issuing a fresh nonce doesn't invalidate the previous one until its own TTL. +- **Why nonce eliminates client-clock dependence**: the nonce is generated at a server-known moment and expires by server clock. A proof carrying that nonce is provably "recent" from the server's own perspective, regardless of what the client's clock says. `iat` becomes advisory (useful for logs, ignored for freshness) unless the client hasn't yet received a nonce (the very first request). + +## Gate 6 — Replay cache (nonce-scoped) + +- Moka `Cache<(String /* nonce */, String /* jti */), ()>` with TTL = 5 minutes (matches max nonce lifetime). +- Every verified proof inserts `(nonce, jti)`. If the same key is inserted again → 401 with `reason = "replay_detected"` and audit line `event = "dpop.replay_detected"`. +- **Test**: send the same proof twice → second call fails; send two proofs with same `jti` but different nonces → both accepted (they belong to different scopes). + +## Gate 6b — HTTP-level DPoP crypto-handshake test binary + +**Why not Hurl.** Hurl scripts are declarative — request template plus expected response. Every DPoP proof is unique per request (fresh `jti`, current `iat`, `htm`/`htu` matching the actual method+URL, ES256 signature from a persistent browser-held keypair, threaded nonce). Hurl has no scripting hook to compute a signed JWT per request. Same fundamental limitation that made us build the OPAQUE crypto-handshake test binary (task #19); same solution. + +**Approach**. A new `src/bin/dpop-hurl-helper.rs` following the same pattern as `src/bin/opaque-hurl-helper.rs` (task #19). Same invocation shape — env-var driven from `tests/api/run.sh`, exit-code contract, no cleanup (server tears down DB between `run.sh` invocations), full crypto against a live server. The `-hurl-helper` naming is deliberate: this binary IS the DPoP counterpart of what Hurl covers for other protocols. + +**Structure**: +- Generate a P-256 keypair once at binary start (persistent across the run — simulates one browser session). +- Compute JWK thumbprint (RFC 7638) for the public key. +- Reqwest-based HTTP client wrapped in a small helper that, for every request: + - Builds a fresh DPoP proof with the correct `htm` (request method), `htu` (canonical URL), `iat` (unix seconds now), `jti` (random UUID), and current `nonce` if one is cached. + - Signs with the persistent P-256 key. + - Attaches `DPoP: ` header. +- Auto-handle the `use_dpop_nonce` challenge: on 401 + `WWW-Authenticate: DPoP error="use_dpop_nonce"`, extract fresh nonce from the `DPoP-Nonce` response header, retry the ORIGINAL request once with the new nonce. Mirrors the SPA fetch interceptor from Gate 4. +- Reuse the OPAQUE handshake helper for login, so this binary exercises the OPAQUE+DPoP composed path end-to-end. + +**Scenarios**: +1. **Happy path** — OPAQUE login includes `dpop_jkt`, first API request lacks nonce → challenge → retry with nonce → 200. Follow-up requests reuse the nonce until rotation. +2. **Fail-open** — login WITHOUT `dpop_jkt`, subsequent requests succeed with no `DPoP` header even in `required` mode (session exempt via `dpop_jkt IS NULL`). +3. **Bound session missing proof** — session created with `dpop_jkt`, request sent WITHOUT `DPoP` header. Expect 401 with `error_type: "DpopVerificationFailed"` in `required` mode; 200 in `opportunistic` mode with a `dpop.header_missing_but_session_bound` audit line. +4. **Wrong `htm`** — sign proof declaring `htm: "POST"` but send GET → 401 `reason = "wrong_htm"`. +5. **Wrong `htu`** — sign for `/api/files/list` but send to `/api/auth/me` → 401 `reason = "wrong_htu"`. +6. **`htu` canonicalisation behind proxy** — send `X-Forwarded-Proto`/`X-Forwarded-Host` headers matching the client-side `htu`; verifier must canonicalise identically. (Guards against Risk #1.) +7. **Stale `iat` on first request** (no-nonce path) — sign with `iat` far in the past → 401 in the no-nonce branch. Establishes the bootstrap-only clock check works. +8. **Nonce rotation** — issue a proof with nonce A, wait past the rotation window so server issues nonce B, present a fresh proof still bearing nonce A but within A's overlap window → still 200. Then wait past A's expiry → 401 challenge for B. +9. **Replay detection** — send the same signed proof twice → second call 401 `reason = "replay_detected"`. Confirms Gate 6. +10. **Thumbprint mismatch** — generate a SECOND keypair mid-run, sign with it → 401 `reason = "jkt_mismatch"`. Session's bound JKT is immutable. +11. **Refresh continuity** — DPoP-signed `POST /api/auth/refresh` succeeds, new session inherits the same `dpop_jkt`, subsequent requests continue to verify with the same keypair. Confirms Gate 7. +12. **Logout wipe** — `POST /api/auth/logout` succeeds; a fresh login on the same reqwest client (new keypair generated by the helper) gets a DIFFERENT `dpop_jkt` — no correlation across the logout boundary. +13. **Malformed proof** — send an unsigned JWT, wrong-alg (RS256), wrong-typ (`jwt` instead of `dpop+jwt`), missing `jwk` in header → 401 with the expected `reason` value in each case. +14. **Bind-time downgrade attempt** — attempt to POST login twice, once with `dpop_jkt` and once without, and confirm the resulting sessions honour their per-session bind status independently. + +**Wiring**: +- Invocation mirrors `opaque-hurl-helper`: `tests/api/run.sh` sets `DPOP_HELPER_BASE_URL` / `DPOP_HELPER_USERNAME` / `DPOP_HELPER_PASSWORD` env vars, then runs `./target/debug/dpop-hurl-helper`. Exit 0 = all scenarios passed; exit 1 = diagnostic to stderr. +- Reuses the same running-server test target already spun up for the OPAQUE helper — no extra server process. Test run sets `OXICLOUD_DPOP_MODE=required` so scenarios exercise the strict path; the fail-open scenario logs in without the JKT to confirm the exemption still works. +- No `Cargo.toml` juggling — the binary is a `[[bin]]` entry alongside the other helpers; the workspace already builds all bins in `cargo build`. +- CI's `just api-test` continues to run everything (Hurl scenarios + `opaque-hurl-helper` + `dpop-hurl-helper`). + +**Test-only deps**: reuse `p256` + `base64` + `serde_json` + `reqwest` already introduced in Gate 5. No extra crates just for tests. + +**What this doesn't cover** — SPA-side journeys (fetch interceptor, IndexedDB persistence, multi-tab, cross-tab logout via `BroadcastChannel`). Those get **Playwright** coverage under Gate 8. The Rust binary owns the wire-protocol contract; Playwright owns the browser-side integration. + +## Gate 7 — Refresh + logout flows + +- **`POST /api/auth/refresh`**: currently unauthenticated (rate-limited public path minting new access tokens from a refresh token cookie). Two changes: + - Client sends DPoP header on the refresh call. Verifier looks up the CURRENT session (pre-refresh) to fetch the `dpop_jkt` for comparison. + - After minting the new session, copy `dpop_jkt` over. The same browser continues to sign with the same key. +- **`POST /api/auth/logout`**: server clears the session row (already does); client calls `clearKeypair()` to also wipe IndexedDB. Next login generates a fresh keypair (which is desirable — post-logout state is fully clean, no correlation between pre- and post-logout activity). +- **Session revocation from admin panel**: no client-side coordination possible. Server clears the row, next client request 401s at the auth layer (session gone), client redirects to login, generates fresh keypair. Same effect. +- **Test**: full login → several DPoP-signed requests → refresh (with DPoP) → several more requests → logout → new login uses different `dpop_jkt`. + +## Gate 8 — Multi-tab handoff + +- IndexedDB is per-origin, shared across tabs of the same profile → concurrent READ is fine. +- Concurrent WRITE (both tabs racing to generate the initial keypair): handled at Gate 2 via `navigator.locks.request("dpop-keypair", ...)`. +- Cross-tab logout: `BroadcastChannel("dpop-cleared").postMessage()` on logout so other open tabs invalidate their in-memory keypair reference and re-`ensureKeypair()` on next request. +- Nonce cache: `sessionStorage` per-tab is fine — each tab does its own initial challenge on cold start. No coordination needed. +- **Test**: Playwright scenario — open two tabs, log in on one, both make requests, both succeed with the same `dpop_jkt` on the server side. + +## Gate 9 — Enforcement rollout + +- Ship `OXICLOUD_DPOP_MODE=opportunistic` as default in the release that lands Gates 1-8. +- Operators monitor: + - `dpop.header_missing_but_session_bound` count — should trend to zero as clients update. + - `dpop.verify_failed{reason}` breakdown — spikes indicate client bugs, not attacks (attacks would be at trickle rate). +- After 2-4 weeks of clean opportunistic-mode telemetry, flip default to `required` in a later release. Pre-existing app-password / legacy sessions with `dpop_jkt IS NULL` still work; they're exempt at the middleware. +- **Documentation deliverable**: operator guide entry explaining the flag, the modes, the observability signals, the upgrade path. + +## Gate 10 — Observability + admin UX + +- **Audit events** (all with `target: "audit"`): + - `dpop.bound_at_login` — info, records `session_id`, `dpop_jkt` prefix, auth method. + - `dpop.header_missing_but_session_bound` — info, opportunistic-mode warning. + - `dpop.verify_failed` with `reason ∈ {invalid_sig, wrong_htm, wrong_htu, expired_iat, jkt_mismatch, replay_detected, nonce_missing, nonce_stale, nonce_unknown, malformed_jws}`. + - `dpop.nonce_challenge_issued` — debug-level, high volume; behind `target: "oxicloud::dpop"` not `audit`. +- **Admin session-list UI**: show a lock icon on sessions where `dpop_jkt IS NOT NULL`. Complements the auth-badges work (task #26). +- **Metrics**: Prometheus counters for `dpop_verify_failed_total{reason}`, `dpop_nonce_challenges_total`. Alerts on `verify_failed` spikes. + +--- + +## Deferred to Phase 2 + +- **RFC 9449 full compliance** — resource-server mode, `ath` claim binding for OIDC access tokens. +- **OIDC-bearer DPoP** — configure the upstream IdP to mint DPoP-bound tokens; resource-side validation. Depends on IdP support (Keycloak ≥ 20, Auth0, Okta, Zitadel all have it). +- **Native Nextcloud client support** — no `SubtleCrypto`; would need embedded ECDSA + secure keystore (Android Keystore / iOS Keychain / OS keyring). Substantially larger project; the current Basic-Auth-over-app-password path stays unchanged. +- **Attested keys via WebAuthn** — bind to TPM / Secure Enclave. Blocks the login-time-compromised-browser attack. Major UX shift (per-request user gesture unless resident-key + silent-assertion flows mature). +- **Detect DPoP capability on upstream IdP** — parse `.well-known/openid-configuration`, warn at boot when `dpop_signing_alg_values_supported` is absent. Half-day of work, orthogonal to this plan, worth its own tiny PR. + +--- + +## Effort estimate + +| Gate | Rough effort | +|---|---| +| 0 — Design record | 0.5 day | +| 1 — Schema | 0.5 day | +| 2 — Client keypair | 1 day | +| 3 — Bind on login | 1.5 days (touches 4 login paths, OIDC needs the bind endpoint) | +| 4 — Fetch interceptor (nonce-aware) | 1 day | +| 5 — Server verifier + middleware | 2 days | +| 5b — DPoP-Nonce service | 1 day | +| 6 — Replay cache | 0.5 day | +| 6b — DPoP hurl-helper binary | 1.5 days | +| 7 — Refresh + logout | 1 day | +| 8 — Multi-tab | 0.5 day | +| 9 — Rollout | 0 (calendar time, no engineering work) | +| 10 — Observability + admin UX | 1 day | +| **Total (Phase 1)** | **~11.5 person-days** | + +## Risks worth naming + +1. **`htu` claim + reverse proxies**. Client sees `https://oxicloud.example`; server behind nginx / Cloudflare sees `http://internal:8086`. Verifier MUST canonicalise both sides identically — `scheme://authority/path` with scheme and authority pulled from `X-Forwarded-Proto` / `X-Forwarded-Host` / `Forwarded`. Reuse the exact same helper the audit-log request span uses for `client_ip`; if it doesn't exist yet, extract one first. Getting this wrong = every request fails with `wrong_htu` in production but passes in dev. + +2. **JWK thumbprint canonicalisation subtlety**. RFC 7638 requires a specific JSON member order (`{"crv":..., "kty":..., "x":..., "y":...}` alphabetical) and NO whitespace. Small deviations from library defaults produce different SHA-256s and thus mismatched thumbprints. Write a unit test with the RFC 7638 §3.1 example vector to lock the canonicalisation on both client and server before shipping. + +3. **JWK header inflation on every request**. Each proof carries a ~300-byte JWT (mostly the JWK header). At high RPS this is measurable network overhead. Not a blocker at OxiCloud's expected load; note it for the perf budget review. + +4. **Nonce cache DoS**. An attacker generating requests without valid sessions can force the server to issue nonces indefinitely, growing the moka cache. Mitigations: (a) cache is size-capped (LRU eviction), (b) rate-limit `/api/auth/dpop/bind` and other unauthenticated paths that could trigger issuance. Neither is DPoP-specific — existing rate limits apply. + +5. **Chromium bug landscape for non-extractable IndexedDB CryptoKeys**. There have been historical issues where a browser update invalidates the stored CryptoKey structure (schema migration in the crypto subsystem). Mitigation: on `sign()` failure, drop the stored keypair, generate fresh, force re-bind on next login. This is a one-time inconvenience per browser upgrade, not a security issue. + +6. **`jsonwebtoken` crate ES256 + embedded JWK support**. Confirm before Gate 5 that the crate handles JWS with a JWK in the header (not a `kid` reference). If not, hand-parse with `p256` + `base64` + `serde_json` — ~50 LoC, no dep surprise. + +7. **Plan assumes cookies stay the primary session carrier**. If a future refactor moves to `Authorization: Bearer ` headers, the DPoP shape shifts slightly (the `ath` claim becomes relevant to bind the DPoP proof to the specific access token). Not a blocker for Phase 1 — cookies-only. + +8. **Interaction with `force_password_change_at_next_login` gate**. Order of middleware layers matters: auth → DPoP verify → password-change gate. A user in reset-pending state must still pass DPoP verification (their session is bound); the reset-flow allowlist endpoints must also be DPoP-verified. Add explicit tests for this interaction. + +--- + +## Success criteria (Phase 1 complete) + +- All four login paths bind a keypair thumbprint to the new session. +- Every `/api/*` request from an SPA session carries a valid DPoP proof (verified in `required` mode). +- App-password sessions (`dpop_jkt IS NULL`) continue to work — no regression for Nextcloud sync clients. +- Copying the session cookie to `curl` on another machine reproducibly fails with 401. +- Audit stream contains actionable telemetry for verify failures, replay attempts, nonce challenges. +- Documentation in `docs/config/authentication.md` explains the modes, the flag, and the rollout guidance for operators. diff --git a/migrations/20261008000000_sessions_dpop_jkt.sql b/migrations/20261008000000_sessions_dpop_jkt.sql new file mode 100644 index 00000000..1efb9e72 --- /dev/null +++ b/migrations/20261008000000_sessions_dpop_jkt.sql @@ -0,0 +1,34 @@ +-- Bind a session cookie to a browser-held ECDSA keypair (DPoP, RFC 9449). +-- +-- Each browser session that supports Web Crypto generates a P-256 keypair +-- with `extractable: false` and stores it in IndexedDB. The public-key JWK +-- thumbprint (RFC 7638, base64url-encoded SHA-256) is sent with the login +-- request and stored here. Middleware then requires every subsequent +-- request on the session to carry a valid DPoP proof signed by the paired +-- private key. Stealing the cookie alone gets an attacker nothing — the +-- private key never leaves the browser's crypto subsystem. +-- +-- Nullable because: +-- * pre-DPoP sessions created before this feature landed; +-- * app-password / Nextcloud-client sessions (Basic Auth, no browser, +-- no Web Crypto) will always have NULL here and are exempted at the +-- middleware; +-- * browsers without SubtleCrypto (very rare in 2026) fail the client- +-- side keypair generation and log in unbound (fail-open per the +-- `docs/plan/dpop.md` threat model). +-- +-- Immutable per-session: set at INSERT time, never updated. That's the +-- point — otherwise an attacker could downgrade a bound session by +-- clearing the column. +-- +-- Length is 43 characters for a base64url-encoded SHA-256 (32 bytes × +-- 4/3 = 43 chars, no padding). Cap at 64 to leave a little slack in +-- case we later support larger thumbprints (e.g. SHA-384 for P-384). +-- +-- No index needed — the column is read alongside the session row by +-- primary key in the auth middleware, never queried in isolation. +ALTER TABLE auth.sessions + ADD COLUMN IF NOT EXISTS dpop_jkt VARCHAR(64); + +COMMENT ON COLUMN auth.sessions.dpop_jkt IS + 'DPoP JWK thumbprint (RFC 7638) binding this session to a browser-held keypair. NULL for app-password / legacy / unbound sessions.'; diff --git a/src/domain/entities/session.rs b/src/domain/entities/session.rs index f0ce4bfb..be96c8fd 100644 --- a/src/domain/entities/session.rs +++ b/src/domain/entities/session.rs @@ -23,6 +23,16 @@ pub struct Session { /// BCL notification would revoke all of the user's sessions rather /// than just the one that logged out on the far end. oidc_sid: Option, + /// DPoP JWK thumbprint (RFC 7638, base64url-encoded SHA-256) binding + /// this session to a browser-held keypair. `None` for app-password + /// / Nextcloud-client / pre-DPoP / unbound sessions — the DPoP + /// middleware exempts them (see `docs/plan/dpop.md`). + /// + /// Immutable per-session: set at construction time, never updated. + /// Downgrading a bound session by clearing the thumbprint would let + /// a stolen cookie replay without the private key — the whole point + /// of the binding is to prevent that. + dpop_jkt: Option, } impl Session { @@ -51,9 +61,25 @@ impl Session { family_id, oidc_id_token: None, oidc_sid: None, + dpop_jkt: None, } } + /// Bind the session to a DPoP-Nonce browser keypair. Called by every + /// login handler when the client presented a well-formed thumbprint + /// in its login request. Absent → session stays unbound (fail-open). + /// + /// Immutable once set: this method panics if called on a session + /// that already has a thumbprint, so a callsite mistake can't + /// silently overwrite the binding. + pub fn with_dpop_jkt(mut self, jkt: String) -> Self { + if self.dpop_jkt.is_some() { + panic!("Session.dpop_jkt is immutable — call at construction time only"); + } + self.dpop_jkt = Some(jkt); + self + } + /// Attach an OIDC ID token — call on sessions minted via the OIDC exchange. /// The token is persisted with the session and re-emitted at logout as /// `id_token_hint` so the IdP can end its own SSO session. @@ -85,6 +111,7 @@ impl Session { family_id: Uuid, oidc_id_token: Option, oidc_sid: Option, + dpop_jkt: Option, ) -> Self { Self { id, @@ -98,6 +125,7 @@ impl Session { family_id, oidc_id_token, oidc_sid, + dpop_jkt, } } @@ -153,4 +181,62 @@ impl Session { pub fn oidc_sid(&self) -> Option<&str> { self.oidc_sid.as_deref() } + + pub fn dpop_jkt(&self) -> Option<&str> { + self.dpop_jkt.as_deref() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fresh_session() -> Session { + Session::new( + Uuid::new_v4(), + "refresh-token".to_string(), + None, + None, + 30, + Uuid::new_v4(), + ) + } + + #[test] + fn new_session_has_no_dpop_binding() { + assert_eq!(fresh_session().dpop_jkt(), None); + } + + #[test] + fn with_dpop_jkt_stores_thumbprint() { + let s = fresh_session().with_dpop_jkt("abc123".to_string()); + assert_eq!(s.dpop_jkt(), Some("abc123")); + } + + #[test] + #[should_panic(expected = "Session.dpop_jkt is immutable")] + fn with_dpop_jkt_rejects_double_bind() { + fresh_session() + .with_dpop_jkt("first".to_string()) + .with_dpop_jkt("second".to_string()); + } + + #[test] + fn from_raw_round_trips_dpop_jkt() { + let s = Session::from_raw( + Uuid::new_v4(), + Uuid::new_v4(), + "token".to_string(), + Utc::now(), + None, + None, + Utc::now(), + false, + Uuid::new_v4(), + None, + None, + Some("thumbprint-xyz".to_string()), + ); + assert_eq!(s.dpop_jkt(), Some("thumbprint-xyz")); + } } diff --git a/src/infrastructure/repositories/pg/session_pg_repository.rs b/src/infrastructure/repositories/pg/session_pg_repository.rs index 2b1ff485..edf3dff2 100644 --- a/src/infrastructure/repositories/pg/session_pg_repository.rs +++ b/src/infrastructure/repositories/pg/session_pg_repository.rs @@ -53,9 +53,9 @@ impl SessionRepository for SessionPgRepository { INSERT INTO auth.sessions ( id, user_id, refresh_token, expires_at, ip_address, user_agent, created_at, revoked, family_id, - oidc_id_token, oidc_sid + oidc_id_token, oidc_sid, dpop_jkt ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 ) "#, ) @@ -70,6 +70,7 @@ impl SessionRepository for SessionPgRepository { .bind(session_clone.family_id()) .bind(session_clone.oidc_id_token()) .bind(session_clone.oidc_sid()) + .bind(session_clone.dpop_jkt()) .execute(&mut **tx) .await .map_err(Self::map_sqlx_error)?; @@ -115,7 +116,7 @@ impl SessionRepository for SessionPgRepository { SELECT id, user_id, refresh_token, expires_at, ip_address, user_agent, created_at, revoked, family_id, - oidc_id_token, oidc_sid + oidc_id_token, oidc_sid, dpop_jkt FROM auth.sessions WHERE id = $1 "#, @@ -137,6 +138,7 @@ impl SessionRepository for SessionPgRepository { row.get("family_id"), row.get("oidc_id_token"), row.get("oidc_sid"), + row.get("dpop_jkt"), )) } @@ -151,7 +153,7 @@ impl SessionRepository for SessionPgRepository { SELECT id, user_id, refresh_token, expires_at, ip_address, user_agent, created_at, revoked, family_id, - oidc_id_token, oidc_sid + oidc_id_token, oidc_sid, dpop_jkt FROM auth.sessions WHERE refresh_token = $1 "#, @@ -173,6 +175,7 @@ impl SessionRepository for SessionPgRepository { row.get("family_id"), row.get("oidc_id_token"), row.get("oidc_sid"), + row.get("dpop_jkt"), )) } @@ -186,7 +189,7 @@ impl SessionRepository for SessionPgRepository { SELECT id, user_id, refresh_token, expires_at, ip_address, user_agent, created_at, revoked, family_id, - oidc_id_token, oidc_sid + oidc_id_token, oidc_sid, dpop_jkt FROM auth.sessions WHERE user_id = $1 ORDER BY created_at DESC @@ -212,6 +215,7 @@ impl SessionRepository for SessionPgRepository { row.get("family_id"), row.get("oidc_id_token"), row.get("oidc_sid"), + row.get("dpop_jkt"), ) }) .collect(); @@ -496,9 +500,9 @@ impl SessionStoragePort for SessionPgRepository { INSERT INTO auth.sessions ( id, user_id, refresh_token, expires_at, ip_address, user_agent, created_at, revoked, family_id, - oidc_id_token, oidc_sid + oidc_id_token, oidc_sid, dpop_jkt ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 ) "#, ) @@ -513,6 +517,7 @@ impl SessionStoragePort for SessionPgRepository { .bind(session_clone.family_id()) .bind(session_clone.oidc_id_token()) .bind(session_clone.oidc_sid()) + .bind(session_clone.dpop_jkt()) .execute(&mut **tx) .await .map_err(Self::map_sqlx_error)?; From fed265c70fb053a05f933c22c23875821f0dd35c Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sat, 8 Aug 2026 13:49:23 +0200 Subject: [PATCH 02/51] feat(DPoP): add frontend dpop library --- frontend/src/lib/auth/dpop.test.ts | 75 +++++++++++++ frontend/src/lib/auth/dpop.ts | 174 +++++++++++++++++++++++++++++ 2 files changed, 249 insertions(+) create mode 100644 frontend/src/lib/auth/dpop.test.ts create mode 100644 frontend/src/lib/auth/dpop.ts diff --git a/frontend/src/lib/auth/dpop.test.ts b/frontend/src/lib/auth/dpop.test.ts new file mode 100644 index 00000000..14a1c669 --- /dev/null +++ b/frontend/src/lib/auth/dpop.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest'; +import { computeJkt } from './dpop'; + +/** + * RFC 7638 §3.1 known-answer vector. + * + * The RFC's example vector is for an RSA key; there's no equally-blessed + * EC vector in the spec text. We instead pin our EC canonicalisation + * against the *rules* by constructing a JWK with: + * * extra members that MUST be excluded from the hash (use, alg, kid), + * * members in non-alphabetical order, + * * whitespace hazards, + * then verifying that our thumbprint matches an INDEPENDENT re-computation + * of the canonical hash. If the alphabetical / whitespace / member-filter + * rules regress, this test fails. + * + * The x/y coordinates below are from a real P-256 keypair generated for + * this test — value not sensitive, generation is deterministic given the + * algorithm output is exposed. + */ +describe('computeJkt', () => { + it('produces a URL-safe base64 SHA-256 thumbprint of the canonical JWK', async () => { + // Generate a real P-256 keypair via SubtleCrypto so the test hits + // real bytes, not a hand-rolled JWK that might drift from what + // the runtime actually emits. + const pair = await crypto.subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-256' }, + true, // extractable so the test can read the JWK independently + ['sign', 'verify'] + ); + const jkt = await computeJkt(pair.publicKey); + + // Base64URL, no padding, exactly 43 chars for a 32-byte SHA-256. + expect(jkt).toMatch(/^[A-Za-z0-9_-]{43}$/); + + // Independent re-computation of the canonical thumbprint — + // same rules RFC 7638 §3.2 pins for EC keys. + const jwk = await crypto.subtle.exportKey('jwk', pair.publicKey); + const canonical = JSON.stringify({ + crv: jwk.crv, + kty: jwk.kty, + x: jwk.x, + y: jwk.y + }); + const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonical)); + const bytes = new Uint8Array(hash); + let s = ''; + for (const b of bytes) s += String.fromCharCode(b); + const expected = btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + + expect(jkt).toBe(expected); + }); + + it('is stable across repeat calls on the same key', async () => { + const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, false, [ + 'sign', + 'verify' + ]); + const first = await computeJkt(pair.publicKey); + const second = await computeJkt(pair.publicKey); + expect(first).toBe(second); + }); + + it('differs across independently-generated keypairs', async () => { + const a = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, false, [ + 'sign', + 'verify' + ]); + const b = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, false, [ + 'sign', + 'verify' + ]); + expect(await computeJkt(a.publicKey)).not.toBe(await computeJkt(b.publicKey)); + }); +}); diff --git a/frontend/src/lib/auth/dpop.ts b/frontend/src/lib/auth/dpop.ts new file mode 100644 index 00000000..92c7dd16 --- /dev/null +++ b/frontend/src/lib/auth/dpop.ts @@ -0,0 +1,174 @@ +/** + * DPoP (RFC 9449) browser-side keypair lifecycle. + * + * Every SPA session that supports Web Crypto generates a P-256 ECDSA keypair + * with `extractable: false` at first login, persists the `CryptoKey` handles + * in IndexedDB, and reuses them for every subsequent request's DPoP proof. + * The raw key bytes live in the browser's crypto subsystem — JS can call + * `sign()` on the handle but never `exportKey()`. That's what defeats + * info-stealer replay: the cookie alone is useless without the private key, + * and the private key never leaves the browser process's crypto boundary. + * + * Fail-open contract: any failure here (SubtleCrypto unavailable, IndexedDB + * blocked by policy, HTTP-not-HTTPS context) must throw or return so the + * caller can log in WITHOUT a `dpop_jkt`. The resulting session lives with + * `session.dpop_jkt = NULL` and is exempted at the middleware. This mirrors + * `docs/plan/dpop.md`'s explicit design — degradation is per-session and + * immutable, so a bound session cannot be downgraded by a later request. + * + * Threat model boundary: same as any `SubtleCrypto` non-extractable + * `CryptoKey`. Defeats today's commodity info-stealers (which target + * cookies via SQLite + OS keyring, not IndexedDB CryptoKey blobs). + * Doesn't defeat a browser process compromised at login time or a + * malicious extension with `webRequest` — those are prerequisites the + * whole SPA relies on being clean. + */ + +const DB_NAME = 'oxicloud-dpop'; +const DB_VERSION = 1; +const STORE = 'keypair'; +const KEY = 'current'; + +/** Base64URL-encode raw bytes, no padding — RFC 7515 §2 (`base64url`). */ +function b64u(bytes: Uint8Array): string { + let s = ''; + for (const b of bytes) s += String.fromCharCode(b); + return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +/** Open (and create-if-missing) the DPoP object store. */ +function openDb(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE); + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +async function readKeypair(): Promise { + const db = await openDb(); + try { + return await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, 'readonly'); + const req = tx.objectStore(STORE).get(KEY); + req.onsuccess = () => resolve((req.result as CryptoKeyPair | undefined) ?? null); + req.onerror = () => reject(req.error); + }); + } finally { + db.close(); + } +} + +async function writeKeypair(pair: CryptoKeyPair): Promise { + const db = await openDb(); + try { + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, 'readwrite'); + tx.objectStore(STORE).put(pair, KEY); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } finally { + db.close(); + } +} + +async function generateKeypair(): Promise { + return crypto.subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-256' }, + // extractable: false — the private key handle cannot be exported. + // SubtleCrypto stores raw bytes in the browser's crypto subsystem + // (Keychain/DPAPI-encrypted at rest on disk). JS holds only an + // opaque reference usable via sign(). + false, + ['sign', 'verify'] + ); +} + +/** + * Return the browser's persistent DPoP keypair. On first call in a fresh + * profile, generates a new P-256 keypair (non-extractable) and persists + * it. Subsequent calls return the SAME handle across the same profile — + * across tabs (shared IndexedDB), across reloads, across sessions until + * `clearKeypair()` is called. + * + * Tab-race guard: two tabs opened simultaneously both call + * `ensureKeypair()` before either has persisted. `navigator.locks` + * serialises them; the second waiter finds the persisted keypair and + * returns it, so both tabs converge on the same handle. + * + * When `navigator.locks` is unavailable (very old Safari), the race is + * theoretically possible but statistically rare; the loser overwrites + * the winner's keypair, which just means the earlier tab's next request + * fails DPoP verification once and forces a re-login. Not catastrophic. + */ +export async function ensureKeypair(): Promise { + const doEnsure = async (): Promise => { + const existing = await readKeypair(); + if (existing) return existing; + const fresh = await generateKeypair(); + await writeKeypair(fresh); + return fresh; + }; + if (typeof navigator !== 'undefined' && navigator.locks?.request) { + return navigator.locks.request('oxicloud-dpop-keypair', doEnsure); + } + return doEnsure(); +} + +/** + * Compute the RFC 7638 JWK thumbprint of the public key — the value we + * send to the server as `dpop_jkt` at login. Base64URL-encoded SHA-256 + * of the CANONICAL JWK (member names alphabetical, no whitespace, only + * the REQUIRED members for the key type — see §3.2 for EC keys). + * + * Canonicalisation is load-bearing: a rogue `{"kty":"EC","crv":"P-256",...}` + * with any deviation (extra whitespace, non-alphabetical order, extra + * members like `use` or `alg`) yields a DIFFERENT hash and thus a + * different thumbprint — the server would reject the binding. RFC 7638 + * §3.1 pins the exact serialisation; we reproduce it here. + */ +export async function computeJkt(pubKey: CryptoKey): Promise { + const jwk = await crypto.subtle.exportKey('jwk', pubKey); + // RFC 7638 §3.2 — for EC keys, the REQUIRED members are crv, kty, + // x, y in ALPHABETICAL order. Any other members (use, alg, kid, …) + // MUST be omitted from the hash input. + const canonical = JSON.stringify({ + crv: jwk.crv, + kty: jwk.kty, + x: jwk.x, + y: jwk.y + }); + const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonical)); + return b64u(new Uint8Array(hash)); +} + +/** + * Drop the persistent keypair — called on logout so the next login + * mints a fresh binding with no correlation across the boundary. + * + * Safe to call when no keypair exists (IndexedDB may be absent in + * private mode after close-and-reopen). + */ +export async function clearKeypair(): Promise { + let db: IDBDatabase; + try { + db = await openDb(); + } catch { + return; // IndexedDB unavailable — nothing to clear + } + try { + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, 'readwrite'); + tx.objectStore(STORE).delete(KEY); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } finally { + db.close(); + } +} From 8b79e26329f036f1aff7141687abf30f60ef4cec Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sat, 8 Aug 2026 14:15:26 +0200 Subject: [PATCH 03/51] feat(DPoP): bing ceremony on login --- frontend/src/lib/api/endpoints/auth.ts | 66 ++++++++- frontend/src/lib/api/endpoints/opaque.test.ts | 6 +- frontend/src/lib/api/endpoints/opaque.ts | 15 +- src/application/dtos/user_dto.rs | 7 + src/application/ports/auth_ports.rs | 11 ++ .../services/auth_application_service.rs | 140 +++++++++++++++++- src/domain/repositories/session_repository.rs | 31 ++++ .../repositories/pg/session_pg_repository.rs | 52 +++++++ src/interfaces/api/handlers/auth_handler.rs | 70 +++++++++ .../api/handlers/opaque_auth_handler.rs | 7 +- 10 files changed, 394 insertions(+), 11 deletions(-) diff --git a/frontend/src/lib/api/endpoints/auth.ts b/frontend/src/lib/api/endpoints/auth.ts index 6c0ac025..a0155f4a 100644 --- a/frontend/src/lib/api/endpoints/auth.ts +++ b/frontend/src/lib/api/endpoints/auth.ts @@ -63,7 +63,65 @@ export async function tryRefresh(): Promise { } } +/** + * 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 { + 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 { + 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 { + // 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 diff --git a/frontend/src/lib/api/endpoints/opaque.test.ts b/frontend/src/lib/api/endpoints/opaque.test.ts index ea31ecad..2c3b52cd 100644 --- a/frontend/src/lib/api/endpoints/opaque.test.ts +++ b/frontend/src/lib/api/endpoints/opaque.test.ts @@ -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 ); diff --git a/frontend/src/lib/api/endpoints/opaque.ts b/frontend/src/lib/api/endpoints/opaque.ts index e687c1e2..031e253d 100644 --- a/frontend/src/lib/api/endpoints/opaque.ts +++ b/frontend/src/lib/api/endpoints/opaque.ts @@ -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 { 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); diff --git a/src/application/dtos/user_dto.rs b/src/application/dtos/user_dto.rs index cb975d7d..4943e532 100644 --- a/src/application/dtos/user_dto.rs +++ b/src/application/dtos/user_dto.rs @@ -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, } #[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] diff --git a/src/application/ports/auth_ports.rs b/src/application/ports/auth_ports.rs index 4a1c6399..a32aca41 100644 --- a/src/application/ports/auth_ports.rs +++ b/src/application/ports/auth_ports.rs @@ -473,6 +473,17 @@ pub trait SessionStoragePort: Send + Sync + 'static { issuer: &str, subject: &str, ) -> Result, 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>; } // ============================================================================ diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 774cba4a..47638a50 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -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 { + 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, ) -> Result { // 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 { // Single-flight: concurrent misses for the same user coalesce // into ONE storage lookup; errors are never cached (same herd diff --git a/src/domain/repositories/session_repository.rs b/src/domain/repositories/session_repository.rs index a25f7c69..f6ae88ef 100644 --- a/src/domain/repositories/session_repository.rs +++ b/src/domain/repositories/session_repository.rs @@ -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 = Result; @@ -25,6 +32,11 @@ impl From 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; + + /// 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<()>; } diff --git a/src/infrastructure/repositories/pg/session_pg_repository.rs b/src/infrastructure/repositories/pg/session_pg_repository.rs index edf3dff2..04d936a3 100644 --- a/src/infrastructure/repositories/pg/session_pg_repository.rs +++ b/src/infrastructure/repositories/pg/session_pg_repository.rs @@ -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) + } } diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index 35f57799..3861db43 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -55,6 +55,7 @@ pub fn auth_protected_routes() -> Router> { // 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>, + CurrentUserId(_user_id): CurrentUserId, + headers: HeaderMap, + Json(dto): Json, +) -> Result { + 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 diff --git a/src/interfaces/api/handlers/opaque_auth_handler.rs b/src/interfaces/api/handlers/opaque_auth_handler.rs index 9d7a2439..e6870ad7 100644 --- a/src/interfaces/api/handlers/opaque_auth_handler.rs +++ b/src/interfaces/api/handlers/opaque_auth_handler.rs @@ -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, } /// 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)?; From 514bbc35ab329e70a4bae18c0fc8d3f78b3a3966 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sat, 8 Aug 2026 14:33:25 +0200 Subject: [PATCH 04/51] feat(DPoP): add nonce on client side --- frontend/src/lib/api/client.test.ts | 112 ++++++++++++++++ frontend/src/lib/api/client.ts | 73 +++++++++- frontend/src/lib/auth/dpop-proof.test.ts | 155 ++++++++++++++++++++++ frontend/src/lib/auth/dpop-proof.ts | 162 +++++++++++++++++++++++ 4 files changed, 499 insertions(+), 3 deletions(-) create mode 100644 frontend/src/lib/auth/dpop-proof.test.ts create mode 100644 frontend/src/lib/auth/dpop-proof.ts diff --git a/frontend/src/lib/api/client.test.ts b/frontend/src/lib/api/client.test.ts index 02157ff6..a3924471 100644 --- a/frontend/src/lib/api/client.test.ts +++ b/frontend/src/lib/api/client.test.ts @@ -1,4 +1,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// vi.mock runs BEFORE all imports; vi.hoisted gives us a shared +// binding that both the mock factory and per-test setup can mutate. +// Reset in each test's beforeEach so tests don't leak state. +const dpopState = vi.hoisted(() => ({ proof: 'proof.value.here' as string | null })); +vi.mock('$lib/auth/dpop-proof', async () => { + const actual = + await vi.importActual('$lib/auth/dpop-proof'); + return { + ...actual, + buildDpopProof: vi.fn(async () => dpopState.proof) + }; +}); + import { createApiFetch } from './client'; const ORIGIN = 'https://cloud.example'; @@ -129,6 +143,104 @@ describe('createApiFetch — 401 refresh/retry parity', () => { }); }); +describe('createApiFetch — DPoP header injection + nonce challenge', () => { + beforeEach(() => { + dpopState.proof = 'proof.value.here'; + }); + + it('attaches a DPoP header on same-origin requests', async () => { + const rawFetch = vi.fn().mockResolvedValue(jsonResponse(200, {})); + const apiFetch = createApiFetch({ + rawFetch, + onSessionExpired: () => {}, + origin: ORIGIN + }); + + await apiFetch(`${ORIGIN}/api/files`); + const [, init] = rawFetch.mock.calls[0]; + const hdrs = new Headers((init as RequestInit)?.headers ?? {}); + expect(hdrs.get('DPoP')).toBe('proof.value.here'); + }); + + it('does NOT attach a DPoP header on cross-origin requests', async () => { + const rawFetch = vi.fn().mockResolvedValue(jsonResponse(200, {})); + const apiFetch = createApiFetch({ + rawFetch, + onSessionExpired: () => {}, + origin: ORIGIN + }); + + await apiFetch('https://third-party.example/api/thing'); + const [, init] = rawFetch.mock.calls[0]; + const hdrs = new Headers((init as RequestInit)?.headers ?? {}); + expect(hdrs.get('DPoP')).toBeNull(); + }); + + it('skips the DPoP header when the keypair is unavailable (fail-open)', async () => { + dpopState.proof = null; + const rawFetch = vi.fn().mockResolvedValue(jsonResponse(200, {})); + const apiFetch = createApiFetch({ + rawFetch, + onSessionExpired: () => {}, + origin: ORIGIN + }); + + const res = await apiFetch(`${ORIGIN}/api/files`); + expect(res.status).toBe(200); + const [, init] = rawFetch.mock.calls[0]; + const hdrs = new Headers((init as RequestInit)?.headers ?? {}); + expect(hdrs.get('DPoP')).toBeNull(); + }); + + it('retries once on a use_dpop_nonce challenge (harvests nonce, rebuilds proof)', async () => { + const challenge = new Response(null, { + status: 401, + headers: { + 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"', + 'DPoP-Nonce': 'srv-fresh' + } + }); + const rawFetch = vi + .fn() + .mockResolvedValueOnce(challenge) + .mockResolvedValueOnce(jsonResponse(200, { ok: true })); + const apiFetch = createApiFetch({ + rawFetch, + onSessionExpired: () => {}, + origin: ORIGIN + }); + + const res = await apiFetch(`${ORIGIN}/api/files`); + expect(res.status).toBe(200); + expect(rawFetch).toHaveBeenCalledTimes(2); // original + one retry + }); + + it('does not loop when the retry ALSO returns use_dpop_nonce', async () => { + // Use an auth-primitive path so the outer 401-refresh path is + // bypassed — this test is scoped to the DPoP inner retry + // only. `mockResolvedValue` (not `Once`) so we can COUNT how + // many times the interceptor called through — it must be + // exactly 2 (original + one retry), never 3. + const challenge = new Response(null, { + status: 401, + headers: { + 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"', + 'DPoP-Nonce': 'srv-fresh' + } + }); + const rawFetch = vi.fn().mockResolvedValue(challenge); + const apiFetch = createApiFetch({ + rawFetch, + onSessionExpired: () => {}, + origin: ORIGIN + }); + + const res = await apiFetch(`${ORIGIN}/api/auth/login`); + expect(res.status).toBe(401); + expect(rawFetch).toHaveBeenCalledTimes(2); + }); +}); + describe('ApiError + apiJson', () => { it('ApiError carries status, statusText, and a descriptive message', async () => { const { ApiError } = await import('./client'); diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index d837b2e3..64e742e5 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -19,6 +19,11 @@ import { getCsrfHeaders } from './csrf'; import { updateFromHeader } from '$lib/stores/serverStatus.svelte'; +import { + buildDpopProof, + isDpopNonceChallenge, + updateNonceFromResponse +} from '$lib/auth/dpop-proof'; /** * Name of the response header the server stamps while a @@ -94,7 +99,7 @@ export function createApiFetch(deps: ApiClientDeps): FetchFn { if (refreshInFlight) return refreshInFlight; refreshInFlight = (async () => { try { - const r = await rawFetch(REFRESH_ENDPOINT, { + const r = await dpopFetch(REFRESH_ENDPOINT, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() }, @@ -110,9 +115,56 @@ export function createApiFetch(deps: ApiClientDeps): FetchFn { return refreshInFlight; } + /** + * Wrap the raw fetch with DPoP proof injection + nonce challenge/retry. + * + * 1. Build proof for the request's method + canonical URL (no query). + * 2. Attach as `DPoP` header. Fail-open if the keypair is unavailable + * (browser without SubtleCrypto / IndexedDB) — we simply skip the + * header and let the request go through unbound; server-side + * middleware exempts unbound sessions. + * 3. After response, harvest a fresh `DPoP-Nonce` if the server sent + * one, so the NEXT request has the current nonce. + * 4. If the response is a nonce challenge (`401 use_dpop_nonce`), + * REBUILD the proof with the just-received nonce and retry ONCE. + * A second challenge on the retry is a bug — surface it as a real + * 401 rather than looping. + * + * Cross-origin requests skip DPoP entirely (privacy — don't leak the + * user's public key to third parties). Request bodies are consumed at + * most once during retry: `init.body` is passed by reference, and the + * only mutating step is `Headers`; a caller-supplied `ReadableStream` + * body would need `duplex: 'half'`, which they'd already have to opt + * into for cross-origin CORS anyway. + */ + async function dpopFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const origin = deps.origin ?? globalThis.location?.origin ?? 'http://localhost'; + const urlStr = urlString(input as RequestInfo | URL); + if (isCrossOrigin(urlStr, origin)) return rawFetch(input, init); + + const method = init?.method ?? 'GET'; + const withProof = async (): Promise => { + const proof = await buildDpopProof(method, urlStr); + const initWithProof: RequestInit = proof + ? { ...init, headers: mergeHeader(init?.headers, 'DPoP', proof) } + : (init ?? {}); + const res = await rawFetch(input, initWithProof); + updateNonceFromResponse(res); + return res; + }; + + const first = await withProof(); + if (!isDpopNonceChallenge(first)) return first; + // `updateNonceFromResponse` already stored the fresh nonce + // carried on this 401; the next `buildDpopProof` will pick it + // up. If the RETRY also produces `use_dpop_nonce`, surface it + // — infinite retry would mask a server-side nonce bug. + return withProof(); + } + const apiFetch: FetchFn = async (input, init) => { const origin = deps.origin ?? globalThis.location?.origin ?? 'http://localhost'; - const response = await rawFetch(input, init); + const response = await dpopFetch(input, init); // Server-status header piggyback — the server stamps // `x-server-status` on every response while a maintenance // event is in progress (see middleware::server_status). Read @@ -168,7 +220,11 @@ export function createApiFetch(deps: ApiClientDeps): FetchFn { } throw new Error('Session expired'); } - const retryResponse = await rawFetch(input, init); + // Retry through dpopFetch (not rawFetch directly) so the + // post-refresh request also carries a valid DPoP proof — + // otherwise a session bound to a keypair would 401 again on + // the retry with `dpop_missing`. + const retryResponse = await dpopFetch(input, init); updateFromHeader(retryResponse.headers.get(SERVER_STATUS_HEADER)); return retryResponse; }; @@ -176,6 +232,17 @@ export function createApiFetch(deps: ApiClientDeps): FetchFn { return apiFetch; } +/** + * Merge a single header into an existing `HeadersInit` (`Headers`, plain + * object, or array-of-pairs), returning a fresh `Headers` so the caller's + * init isn't mutated. Preserves case-insensitivity via the `Headers` API. + */ +function mergeHeader(base: HeadersInit | undefined, name: string, value: string): Headers { + const merged = new Headers(base ?? {}); + merged.set(name, value); + return merged; +} + // ── Default singleton ────────────────────────────────────────────────────── let sessionExpiredHandler: () => void = () => { diff --git a/frontend/src/lib/auth/dpop-proof.test.ts b/frontend/src/lib/auth/dpop-proof.test.ts new file mode 100644 index 00000000..9e29add4 --- /dev/null +++ b/frontend/src/lib/auth/dpop-proof.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Mock the keypair source: jsdom has no IndexedDB, so the real +// `ensureKeypair()` throws. We generate a real P-256 pair via +// SubtleCrypto (Node 20+ ships it natively as `crypto.webcrypto`, +// exposed on `globalThis.crypto` in the test env) — same shape the +// browser sees, real signing bytes exercised end-to-end. +const KEYPAIR_PROMISE = crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [ + 'sign', + 'verify' +]); +vi.mock('./dpop', () => ({ + ensureKeypair: () => KEYPAIR_PROMISE +})); + +import { + buildDpopProof, + canonicalHtu, + clearNonce, + isDpopNonceChallenge, + updateNonceFromResponse +} from './dpop-proof'; + +/** Base64URL decode → bytes. Only used for test assertions. */ +function b64uDecode(s: string): Uint8Array { + const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4)); + const b64 = s.replace(/-/g, '+').replace(/_/g, '/') + pad; + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +function b64uDecodeJson(s: string): Record { + return JSON.parse(new TextDecoder().decode(b64uDecode(s))); +} + +beforeEach(() => { + // Nonce state is module-level — reset between tests so cross-file + // order doesn't leak nonces from one test into another. + clearNonce(); +}); + +describe('canonicalHtu', () => { + it('strips query and fragment', () => { + expect(canonicalHtu('https://oxi.example/api/x?y=1&z=2#frag')).toBe( + 'https://oxi.example/api/x' + ); + }); + it('resolves relative against location.origin', () => { + expect(canonicalHtu('/api/auth/me')).toMatch(/^https?:\/\/.+\/api\/auth\/me$/); + }); +}); + +describe('isDpopNonceChallenge', () => { + function res(status: number, wwwAuth?: string): Response { + return new Response(null, { + status, + headers: wwwAuth ? { 'WWW-Authenticate': wwwAuth } : {} + }); + } + + it('matches DPoP scheme + use_dpop_nonce error', () => { + expect(isDpopNonceChallenge(res(401, 'DPoP error="use_dpop_nonce"'))).toBe(true); + expect(isDpopNonceChallenge(res(401, 'dpop error="use_dpop_nonce"'))).toBe(true); + expect(isDpopNonceChallenge(res(401, 'DPoP error=use_dpop_nonce'))).toBe(true); + }); + it('rejects other errors', () => { + expect(isDpopNonceChallenge(res(401, 'DPoP error="invalid_dpop_proof"'))).toBe(false); + expect(isDpopNonceChallenge(res(401, 'Bearer error="invalid_token"'))).toBe(false); + }); + it('requires status 401', () => { + expect(isDpopNonceChallenge(res(200, 'DPoP error="use_dpop_nonce"'))).toBe(false); + }); + it('handles missing header', () => { + expect(isDpopNonceChallenge(res(401))).toBe(false); + }); +}); + +describe('buildDpopProof', () => { + it('produces a compact JWS with the expected header + claims', async () => { + const proof = await buildDpopProof('POST', 'https://oxi.example/api/foo?bar=1'); + expect(proof).not.toBeNull(); + const parts = proof!.split('.'); + expect(parts).toHaveLength(3); + + const header = b64uDecodeJson(parts[0]); + expect(header.typ).toBe('dpop+jwt'); + expect(header.alg).toBe('ES256'); + const jwk = header.jwk as Record; + expect(jwk.kty).toBe('EC'); + expect(jwk.crv).toBe('P-256'); + expect(jwk.x).toMatch(/^[A-Za-z0-9_-]+$/); + expect(jwk.y).toMatch(/^[A-Za-z0-9_-]+$/); + // Only the RFC 7638 members — no `use`, `alg`, `kid` etc leaking in. + expect(Object.keys(jwk).sort()).toEqual(['crv', 'kty', 'x', 'y']); + + const claims = b64uDecodeJson(parts[1]); + expect(claims.htm).toBe('POST'); + // htu MUST NOT carry the query string + expect(claims.htu).toBe('https://oxi.example/api/foo'); + expect(typeof claims.iat).toBe('number'); + expect(typeof claims.jti).toBe('string'); + expect((claims.jti as string).length).toBeGreaterThan(10); + // No nonce sent when none has been received yet — bootstrap branch. + expect(claims.nonce).toBeUndefined(); + + // Signature bytes are 64 for P-256 raw (R || S) + expect(b64uDecode(parts[2]).length).toBe(64); + }); + + it('includes the current nonce claim once one has been received', async () => { + updateNonceFromResponse( + new Response(null, { status: 200, headers: { 'DPoP-Nonce': 'srv-nonce-abc' } }) + ); + const proof = await buildDpopProof('GET', '/api/auth/me'); + const claims = b64uDecodeJson(proof!.split('.')[1]); + expect(claims.nonce).toBe('srv-nonce-abc'); + }); + + it('mints a fresh jti per call so replay-cache can distinguish', async () => { + const a = await buildDpopProof('GET', '/api/foo'); + const b = await buildDpopProof('GET', '/api/foo'); + const jtiA = b64uDecodeJson(a!.split('.')[1]).jti as string; + const jtiB = b64uDecodeJson(b!.split('.')[1]).jti as string; + expect(jtiA).not.toBe(jtiB); + }); + + it('uppercases the method in the htm claim', async () => { + const proof = await buildDpopProof('post', '/api/x'); + const claims = b64uDecodeJson(proof!.split('.')[1]); + expect(claims.htm).toBe('POST'); + }); +}); + +describe('updateNonceFromResponse', () => { + it('extracts and stores DPoP-Nonce', async () => { + updateNonceFromResponse( + new Response(null, { status: 200, headers: { 'DPoP-Nonce': 'nonce-1' } }) + ); + const proof = await buildDpopProof('GET', '/api/x'); + expect(b64uDecodeJson(proof!.split('.')[1]).nonce).toBe('nonce-1'); + }); + it('is a no-op when the header is absent', async () => { + updateNonceFromResponse(new Response(null, { status: 200 })); + const proof = await buildDpopProof('GET', '/api/x'); + expect(b64uDecodeJson(proof!.split('.')[1]).nonce).toBeUndefined(); + }); + it('overwrites when a new nonce arrives', async () => { + updateNonceFromResponse(new Response(null, { status: 200, headers: { 'DPoP-Nonce': 'v1' } })); + updateNonceFromResponse(new Response(null, { status: 200, headers: { 'DPoP-Nonce': 'v2' } })); + const proof = await buildDpopProof('GET', '/api/x'); + expect(b64uDecodeJson(proof!.split('.')[1]).nonce).toBe('v2'); + }); +}); diff --git a/frontend/src/lib/auth/dpop-proof.ts b/frontend/src/lib/auth/dpop-proof.ts new file mode 100644 index 00000000..72610b08 --- /dev/null +++ b/frontend/src/lib/auth/dpop-proof.ts @@ -0,0 +1,162 @@ +/** + * Build a DPoP proof JWT (RFC 9449) signed with the browser's persistent + * P-256 keypair (see `./dpop.ts`), and manage the `DPoP-Nonce` state + * that the server issues in response headers. + * + * A proof is minted PER request and carries the exact `htm` (method) + + * `htu` (target URL, no query) it's bound to. Server rejects any + * mismatch — a stolen proof cannot be replayed against a different URL + * or method, and the `jti` (unique per proof) prevents even same-URL + * replay within the nonce's lifetime. + * + * Nonce handling: at Gate 5b the server issues a `DPoP-Nonce` response + * header rotating every ~2min. This module holds the current nonce in + * module-level state (mirrored to `sessionStorage` so it survives a + * `/api/auth/me` remount but is per-tab per the plan — each tab does + * its own bootstrap challenge). At Gate 4 the server does not yet emit + * nonces; the client sends proofs without the `nonce` claim, which + * server-side (Gate 5) accepts on the bootstrap-only clock branch. + */ + +import { ensureKeypair } from './dpop'; + +const NONCE_STORAGE_KEY = 'oxicloud-dpop-nonce'; + +let currentNonce: string | null = null; + +/** Initialise nonce state from sessionStorage on first import. */ +function loadNonceOnce(): void { + if (currentNonce !== null) return; + try { + const stored = sessionStorage.getItem(NONCE_STORAGE_KEY); + if (stored) currentNonce = stored; + } catch { + /* sessionStorage may be absent (SSR / privacy mode) — ignore */ + } +} + +/** Update the nonce state from a fresh `DPoP-Nonce` response header. */ +export function updateNonceFromResponse(response: Response): void { + const fresh = response.headers.get('DPoP-Nonce'); + if (!fresh || fresh === currentNonce) return; + currentNonce = fresh; + try { + sessionStorage.setItem(NONCE_STORAGE_KEY, fresh); + } catch { + /* sessionStorage full / disabled — keep in-memory copy */ + } +} + +/** Wipe the current nonce — called on logout so a new session bootstraps fresh. */ +export function clearNonce(): void { + currentNonce = null; + try { + sessionStorage.removeItem(NONCE_STORAGE_KEY); + } catch { + /* ignore */ + } +} + +/** Base64URL encode, no padding — RFC 7515 §2. */ +function b64u(bytes: Uint8Array): string { + let s = ''; + for (const b of bytes) s += String.fromCharCode(b); + return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function b64uJson(value: unknown): string { + return b64u(new TextEncoder().encode(JSON.stringify(value))); +} + +/** + * Canonicalise a URL for the `htu` claim (RFC 9449 §4.2). Rules: + * * scheme + authority (host + port) + path + * * NO query string, NO fragment + * * lowercase scheme + host per URI normalisation + * + * Accepts absolute (`https://oxi.example/api/x`) or relative + * (`/api/x`) URLs; relative resolves against `location.origin`. + */ +export function canonicalHtu(url: string): string { + const base = typeof location !== 'undefined' ? location.origin : 'http://localhost'; + const u = new URL(url, base); + return `${u.protocol}//${u.host}${u.pathname}`; +} + +/** + * Build a signed DPoP proof for the given method+URL. Returns the + * compact JWS string ready to drop into a `DPoP` HTTP header, or + * `null` when the keypair is unavailable (SubtleCrypto absent, + * IndexedDB blocked, etc.) — caller MUST proceed without the header + * in that case (fail-open contract, matches `docs/plan/dpop.md`). + * + * Each call mints a fresh `jti` (random UUID) and stamps a current + * `iat`, so two calls made in the same tick still produce different + * proofs — replay-cache friendly by construction. + */ +export async function buildDpopProof(method: string, url: string): Promise { + loadNonceOnce(); + + let keypair: CryptoKeyPair; + try { + keypair = await ensureKeypair(); + } catch (err) { + console.debug('dpop-proof: keypair unavailable', err); + return null; + } + + // Export the public key JWK — becomes the `jwk` header member. + // Extractable is a property of the PUBLIC key (we only ever set + // `extractable: false` on generation for the private half); the + // public half is always extractable in ECDSA/`P-256` regardless. + const jwk = await crypto.subtle.exportKey('jwk', keypair.publicKey); + + const header = { + typ: 'dpop+jwt', + alg: 'ES256', + jwk: { + crv: jwk.crv, + kty: jwk.kty, + x: jwk.x, + y: jwk.y + } + }; + + const claims: Record = { + htm: method.toUpperCase(), + htu: canonicalHtu(url), + iat: Math.floor(Date.now() / 1000), + jti: crypto.randomUUID() + }; + if (currentNonce) claims.nonce = currentNonce; + + const signingInput = `${b64uJson(header)}.${b64uJson(claims)}`; + const signatureBytes = new Uint8Array( + await crypto.subtle.sign( + // ES256: raw R || S concatenation (64 bytes for P-256) — RFC + // 7515 A.3. SubtleCrypto emits exactly this format; no DER + // unwrapping needed. + { name: 'ECDSA', hash: 'SHA-256' }, + keypair.privateKey, + new TextEncoder().encode(signingInput) + ) + ); + return `${signingInput}.${b64u(signatureBytes)}`; +} + +/** + * True when the current 401 response is the server's DPoP-Nonce + * challenge: `WWW-Authenticate: DPoP error="use_dpop_nonce"`. The + * fetch interceptor uses this to decide whether to retry the original + * request once with a freshly-received nonce (which the same response + * carries in its `DPoP-Nonce` header). + */ +export function isDpopNonceChallenge(response: Response): boolean { + if (response.status !== 401) return false; + const auth = response.headers.get('WWW-Authenticate') ?? ''; + // Header syntax per RFC 6750 §3 / RFC 9449 §7.1: whitespace-tolerant + // scheme + comma-separated key="value" pairs. Case-insensitive + // scheme + key match; strict "use_dpop_nonce" for the error value. + if (!/^\s*DPoP(\s|,|$)/i.test(auth)) return false; + return /error\s*=\s*"?use_dpop_nonce"?/i.test(auth); +} From 2e6789e5064be7bce427c5c51fd1549bb7709a63 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sat, 8 Aug 2026 15:08:35 +0200 Subject: [PATCH 05/51] feat(DPoP): add verification + X-Forwarded-Host X-Forwarded-Proto --- Cargo.lock | 1 + Cargo.toml | 4 + src/application/ports/auth_ports.rs | 6 +- .../services/auth_application_service.rs | 6 +- src/common/config.rs | 59 +++ src/common/di.rs | 9 + src/domain/repositories/session_repository.rs | 6 +- .../repositories/pg/session_pg_repository.rs | 12 +- src/infrastructure/services/dpop_verifier.rs | 492 ++++++++++++++++++ src/infrastructure/services/mod.rs | 2 + src/interfaces/middleware/dpop.rs | 302 +++++++++++ src/interfaces/middleware/mod.rs | 1 + 12 files changed, 879 insertions(+), 21 deletions(-) create mode 100644 src/infrastructure/services/dpop_verifier.rs create mode 100644 src/interfaces/middleware/dpop.rs diff --git a/Cargo.lock b/Cargo.lock index db5d4d9c..b3d44d27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4490,6 +4490,7 @@ dependencies = [ "nom-exif", "opaque-ke", "ort", + "p256", "pdf-extract", "percent-encoding", "quick-xml 0.41.0", diff --git a/Cargo.toml b/Cargo.toml index a1cf19fb..9806461b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,6 +63,10 @@ rand_core = { version = "0.6", features = ["std", "getrandom"] } # changing it invalidates every user's registration record, plan a # migration before touching. `argon2` feature gates the memory-hard KSF. opaque-ke = { version = "3", features = ["argon2"] } +# DPoP (RFC 9449) proof verification — ECDSA P-256 signatures. Transitive +# via jsonwebtoken's rust_crypto feature; declared here for direct use in +# `infrastructure::services::dpop_verifier`. +p256 = { version = "0.13", features = ["ecdsa"] } quick-xml = "0.41.0" dotenvy = "0.15.7" moka = { version = "0.12.15", features = ["future", "sync"] } diff --git a/src/application/ports/auth_ports.rs b/src/application/ports/auth_ports.rs index a32aca41..414de2d6 100644 --- a/src/application/ports/auth_ports.rs +++ b/src/application/ports/auth_ports.rs @@ -479,11 +479,7 @@ pub trait SessionStoragePort: Send + Sync + 'static { /// 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>; + async fn bind_dpop_jkt(&self, session_id: Uuid, dpop_jkt: &str) -> Result<(), DomainError>; } // ============================================================================ diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 47638a50..5ba0d7c7 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -2263,7 +2263,11 @@ impl AuthApplicationService { "dpop_jkt must be a 43-character base64url SHA-256 thumbprint (RFC 7638)", ) })?; - match self.session_storage.bind_dpop_jkt(session_id, &validated).await { + match self + .session_storage + .bind_dpop_jkt(session_id, &validated) + .await + { Ok(()) => { tracing::info!( target: "audit", diff --git a/src/common/config.rs b/src/common/config.rs index 129c4f20..a9b62229 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -1448,6 +1448,55 @@ pub struct AuthConfig { /// /// Env: `OXICLOUD_REQUIRE_VERIFIED_EMAIL` (default `false`). pub require_verified_email: bool, + + /// DPoP session-binding enforcement (RFC 9449). Bound sessions — + /// those created with a `dpop_jkt` supplied at login — carry a + /// browser-held keypair thumbprint; the middleware verifies a + /// per-request signed proof so that stealing the session cookie + /// alone is useless without the private key. + /// + /// Modes (see `DpopMode` enum): + /// * `Off` (default) — middleware is a pass-through; no + /// verification even when a proof is present. Ship-safe + /// default while the client rollout catches up. + /// * `Opportunistic` — verify when a proof is present, reject + /// mismatches; skip when absent. Warn on + /// `dpop.header_missing_but_session_bound`. Rollout mode. + /// * `Required` — bound sessions MUST present a valid proof. + /// Unbound sessions (`dpop_jkt IS NULL` — app passwords, + /// Nextcloud clients, legacy) remain exempt at the + /// middleware level. + /// + /// Env: `OXICLOUD_DPOP_MODE` in `{off,opportunistic,required}` + /// (default `off`). + pub dpop_mode: DpopMode, +} + +/// DPoP session-binding enforcement mode. See `AuthConfig::dpop_mode` +/// and `docs/plan/dpop.md` for the rollout strategy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DpopMode { + /// Middleware pass-through — DPoP header is neither required nor + /// verified. Default: safe while clients roll out proof-signing. + #[default] + Off, + /// Verify when present, allow when absent. Bound sessions still + /// get a warning audit line when they arrive without a proof. + Opportunistic, + /// Bound sessions (`dpop_jkt IS NOT NULL`) MUST present a valid + /// proof or 401. Unbound sessions remain exempt. + Required, +} + +impl DpopMode { + pub fn from_env_str(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "off" => Some(Self::Off), + "opportunistic" => Some(Self::Opportunistic), + "required" => Some(Self::Required), + _ => None, + } + } } /// Self-service auth method. Exposed as `AuthConfig::allowed_auth_methods` @@ -1583,6 +1632,7 @@ impl Default for AuthConfig { auth_policies: Vec::new(), allowed_auth_methods: vec![AuthMethod::Password, AuthMethod::MagicLink], require_verified_email: false, + dpop_mode: DpopMode::Off, } } } @@ -2978,6 +3028,15 @@ impl AppConfig { } } + if let Ok(v) = env::var("OXICLOUD_DPOP_MODE") { + match DpopMode::from_env_str(&v) { + Some(mode) => config.auth.dpop_mode = mode, + None => panic!( + "OXICLOUD_DPOP_MODE={v:?} — expected one of off / opportunistic / required" + ), + } + } + if let Ok(v) = env::var("OXICLOUD_REQUIRE_VERIFIED_EMAIL") { config.auth.require_verified_email = v.parse::().unwrap_or(false); } diff --git a/src/common/di.rs b/src/common/di.rs index f57c805b..f16ded1a 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -2102,6 +2102,9 @@ impl AppServiceFactory { opaque_service, opaque_repo, opaque_login_exchange, + dpop_nonce_service: Arc::new( + crate::infrastructure::services::dpop_nonce_service::DpopNonceService::new(), + ), nextcloud: nextcloud_services, admin_settings_service: None, storage_settings_service: None, @@ -2874,6 +2877,12 @@ pub struct AppState { pub opaque_login_exchange: Option< Arc, >, + /// DPoP nonce pool. Always populated (even in `dpop_mode = off`) + /// so switching mode via env-flip needs no restart-time wiring + /// change. Cheap-to-construct in-memory moka cache; unused paths + /// pay only allocation cost at boot. + pub dpop_nonce_service: + Arc, pub nextcloud: Option, pub admin_settings_service: Option>, /// WASM plugin management (list/install/toggle/remove), backing the admin diff --git a/src/domain/repositories/session_repository.rs b/src/domain/repositories/session_repository.rs index f6ae88ef..48589d4e 100644 --- a/src/domain/repositories/session_repository.rs +++ b/src/domain/repositories/session_repository.rs @@ -121,9 +121,5 @@ pub trait SessionRepository: Send + Sync + 'static { /// 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<()>; + async fn bind_dpop_jkt(&self, session_id: Uuid, dpop_jkt: &str) -> SessionRepositoryResult<()>; } diff --git a/src/infrastructure/repositories/pg/session_pg_repository.rs b/src/infrastructure/repositories/pg/session_pg_repository.rs index 04d936a3..4da55abe 100644 --- a/src/infrastructure/repositories/pg/session_pg_repository.rs +++ b/src/infrastructure/repositories/pg/session_pg_repository.rs @@ -469,11 +469,7 @@ impl SessionRepository for SessionPgRepository { Ok(result.rows_affected()) } - async fn bind_dpop_jkt( - &self, - session_id: Uuid, - dpop_jkt: &str, - ) -> SessionRepositoryResult<()> { + 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 @@ -648,11 +644,7 @@ impl SessionStoragePort for SessionPgRepository { .map_err(DomainError::from) } - async fn bind_dpop_jkt( - &self, - session_id: Uuid, - dpop_jkt: &str, - ) -> Result<(), DomainError> { + 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) diff --git a/src/infrastructure/services/dpop_verifier.rs b/src/infrastructure/services/dpop_verifier.rs new file mode 100644 index 00000000..d620cfe1 --- /dev/null +++ b/src/infrastructure/services/dpop_verifier.rs @@ -0,0 +1,492 @@ +//! DPoP proof verifier (RFC 9449) — pure functions over a compact JWS. +//! +//! Consumes a DPoP header value produced by +//! `frontend/src/lib/auth/dpop-proof.ts` (or the `dpop-hurl-helper` +//! test binary — Gate 6b) and returns a typed verdict. +//! +//! Nothing here touches the DB or the request extractor pipeline — +//! that's the middleware's job (see `src/interfaces/middleware/dpop.rs`). +//! Keeping the verifier pure makes the failure-mode matrix trivially +//! unit-testable: `verify(proof, method, htu, expected_jkt, now)`. +//! +//! **Nonce validation is a caller responsibility for now** — the +//! nonce claim is extracted and returned as part of the OK verdict, +//! but the caller (middleware) validates it against the nonce +//! service. Wiring lands in Gate 5b; at Gate 5 the middleware +//! ignores the nonce field (opportunistic path). +//! +//! Ciphersuite: **ES256 ONLY** (ECDSA P-256 + SHA-256). Any other +//! `alg` or `crv` is a hard reject — RFC 9449 §4 mandates support +//! for ES256 and we don't accept anything looser. + +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64_URL_NO_PAD; +use sha2::{Digest, Sha256}; + +/// Per-request context the verifier compares proof claims against. +#[derive(Debug, Clone)] +pub struct DpopRequestContext<'a> { + /// Uppercase HTTP method (e.g. `"POST"`). + pub htm: &'a str, + /// Canonical target URL: `scheme://authority/path` — NO query, NO + /// fragment. Middleware builds this from the external scheme + + /// host (`X-Forwarded-*`-aware) + `OriginalUri` path. + pub htu: &'a str, + /// Server clock (unix seconds). Injected so tests can pin it + /// deterministically. + pub now_secs: i64, + /// Session's stored thumbprint — set at login (`session.dpop_jkt`). + /// When present, the proof's JWK thumbprint MUST match. + pub expected_jkt: Option<&'a str>, +} + +/// Successful verify outcome — the middleware may still need to +/// validate the nonce (Gate 5b) and jti (Gate 6, replay cache). +#[derive(Debug, Clone)] +pub struct DpopVerified { + /// RFC 7638 JWK thumbprint of the proof's public key. + /// Middleware compares to `session.dpop_jkt` (already done here + /// when `expected_jkt` was set) and may audit-log this value. + pub jkt: String, + /// Nonce claim from the proof, if any. Bootstrap-only branch has + /// `None` — the very first request per session precedes the + /// server-issued nonce, so the client can't include it. + pub nonce: Option, + /// Unique proof id — the replay cache in Gate 6 keys off this. + pub jti: String, + /// Claimed issue time (unix seconds) — informational when nonce + /// is present (server clock is authoritative via nonce validity), + /// bounded ±30s when no nonce yet (bootstrap branch). + pub iat: i64, +} + +/// Machine-readable failure reasons. Stringly matched by the middleware +/// for the audit `reason=` field — DO NOT rename variants without +/// coordinating with dashboards. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DpopVerifyError { + /// JWS isn't three base64url segments, or a segment fails to + /// decode, or the JSON header/claims fail to parse. + Malformed, + /// `typ` header field is not `"dpop+jwt"`. + WrongTyp, + /// `alg` header field is not `"ES256"`. + WrongAlg, + /// `jwk` header member is missing or not an EC/P-256 public key. + WrongJwk, + /// ECDSA signature does not verify over `header.payload`. + SignatureInvalid, + /// `htm` claim doesn't match the request method. + WrongHtm, + /// `htu` claim doesn't match the canonical request URL. + WrongHtu, + /// `iat` claim is missing / non-numeric. + IatMissing, + /// `iat` claim is outside the ±30s bootstrap window (no nonce yet). + IatOutOfWindow, + /// `jti` claim is missing / empty. + JtiMissing, + /// Proof's JWK thumbprint doesn't match `expected_jkt`. + JktMismatch, +} + +impl DpopVerifyError { + /// Stable machine-readable reason string for audit lines. + pub fn reason(&self) -> &'static str { + match self { + Self::Malformed => "malformed_jws", + Self::WrongTyp => "wrong_typ", + Self::WrongAlg => "wrong_alg", + Self::WrongJwk => "wrong_jwk", + Self::SignatureInvalid => "signature_invalid", + Self::WrongHtm => "wrong_htm", + Self::WrongHtu => "wrong_htu", + Self::IatMissing => "iat_missing", + Self::IatOutOfWindow => "iat_out_of_window", + Self::JtiMissing => "jti_missing", + Self::JktMismatch => "jkt_mismatch", + } + } +} + +pub type DpopVerifyResult = Result; + +/// ±30s tolerance on the `iat` claim when NO nonce is present +/// (bootstrap branch). Once Gate 5b lands, requests carrying a +/// server-issued nonce bypass this check — nonce validity acts as +/// the authoritative freshness signal. +const IAT_BOOTSTRAP_TOLERANCE_SECS: i64 = 30; + +/// Verify a DPoP proof against a request context. See file doc for +/// scope: nonce/jti/replay checks are the caller's responsibility. +pub fn verify(proof: &str, ctx: &DpopRequestContext<'_>) -> DpopVerifyResult { + // ── 1. Split the compact JWS into three segments ───────────── + let mut parts = proof.split('.'); + let (h_b64, p_b64, s_b64) = match (parts.next(), parts.next(), parts.next(), parts.next()) { + (Some(h), Some(p), Some(s), None) => (h, p, s), + _ => return Err(DpopVerifyError::Malformed), + }; + + let header_bytes = B64_URL_NO_PAD + .decode(h_b64) + .map_err(|_| DpopVerifyError::Malformed)?; + let payload_bytes = B64_URL_NO_PAD + .decode(p_b64) + .map_err(|_| DpopVerifyError::Malformed)?; + let signature = B64_URL_NO_PAD + .decode(s_b64) + .map_err(|_| DpopVerifyError::Malformed)?; + + // ── 2. Parse header + validate typ/alg/jwk ──────────────────── + let header: serde_json::Value = + serde_json::from_slice(&header_bytes).map_err(|_| DpopVerifyError::Malformed)?; + + if header.get("typ").and_then(|v| v.as_str()) != Some("dpop+jwt") { + return Err(DpopVerifyError::WrongTyp); + } + if header.get("alg").and_then(|v| v.as_str()) != Some("ES256") { + return Err(DpopVerifyError::WrongAlg); + } + let jwk = header.get("jwk").ok_or(DpopVerifyError::WrongJwk)?; + if jwk.get("kty").and_then(|v| v.as_str()) != Some("EC") { + return Err(DpopVerifyError::WrongJwk); + } + if jwk.get("crv").and_then(|v| v.as_str()) != Some("P-256") { + return Err(DpopVerifyError::WrongJwk); + } + let x_b64 = jwk + .get("x") + .and_then(|v| v.as_str()) + .ok_or(DpopVerifyError::WrongJwk)?; + let y_b64 = jwk + .get("y") + .and_then(|v| v.as_str()) + .ok_or(DpopVerifyError::WrongJwk)?; + + // ── 3. Verify the ECDSA signature ───────────────────────────── + // JWS ES256 signature is raw R||S (64 bytes for P-256), NOT DER + // — RFC 7515 A.3. `p256::ecdsa::Signature::from_slice` accepts + // exactly that layout. + let x_bytes = B64_URL_NO_PAD + .decode(x_b64) + .map_err(|_| DpopVerifyError::WrongJwk)?; + let y_bytes = B64_URL_NO_PAD + .decode(y_b64) + .map_err(|_| DpopVerifyError::WrongJwk)?; + if x_bytes.len() != 32 || y_bytes.len() != 32 { + return Err(DpopVerifyError::WrongJwk); + } + // SEC1 uncompressed point: 0x04 || X || Y. + let mut sec1 = Vec::with_capacity(65); + sec1.push(0x04); + sec1.extend_from_slice(&x_bytes); + sec1.extend_from_slice(&y_bytes); + + use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier}; + let vkey = VerifyingKey::from_sec1_bytes(&sec1).map_err(|_| DpopVerifyError::WrongJwk)?; + let sig = Signature::from_slice(&signature).map_err(|_| DpopVerifyError::SignatureInvalid)?; + + // Signing input is EXACT bytes: base64url(header) || '.' || + // base64url(payload). Preserve the caller's encoding — do NOT + // re-encode, since serde_json re-serialisation may reorder + // members and break signature. + let signing_input = format!("{h_b64}.{p_b64}"); + vkey.verify(signing_input.as_bytes(), &sig) + .map_err(|_| DpopVerifyError::SignatureInvalid)?; + + // ── 4. Parse claims + validate htm/htu/iat/jti ──────────────── + let claims: serde_json::Value = + serde_json::from_slice(&payload_bytes).map_err(|_| DpopVerifyError::Malformed)?; + + let htm = claims.get("htm").and_then(|v| v.as_str()).unwrap_or(""); + if !htm.eq_ignore_ascii_case(ctx.htm) { + return Err(DpopVerifyError::WrongHtm); + } + let htu = claims.get("htu").and_then(|v| v.as_str()).unwrap_or(""); + if htu != ctx.htu { + return Err(DpopVerifyError::WrongHtu); + } + let iat = claims + .get("iat") + .and_then(|v| v.as_i64()) + .ok_or(DpopVerifyError::IatMissing)?; + let jti = claims + .get("jti") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .ok_or(DpopVerifyError::JtiMissing)? + .to_string(); + let nonce = claims + .get("nonce") + .and_then(|v| v.as_str()) + .map(str::to_owned); + + // iat freshness check: authoritative only when NO nonce is + // present (bootstrap branch). Gate 5b will bypass this when a + // nonce is available — nonce validity is server-clock-based, so + // it moots any client-clock skew. + if nonce.is_none() && (iat - ctx.now_secs).abs() > IAT_BOOTSTRAP_TOLERANCE_SECS { + return Err(DpopVerifyError::IatOutOfWindow); + } + + // ── 5. Compute JWK thumbprint (RFC 7638 §3.2 EC members) ────── + let canonical = format!(r#"{{"crv":"P-256","kty":"EC","x":"{x_b64}","y":"{y_b64}"}}"#,); + let jkt = B64_URL_NO_PAD.encode(Sha256::digest(canonical.as_bytes())); + + if let Some(expected) = ctx.expected_jkt + && expected != jkt + { + return Err(DpopVerifyError::JktMismatch); + } + + Ok(DpopVerified { + jkt, + nonce, + jti, + iat, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use p256::ecdsa::{Signature, SigningKey, signature::Signer}; + + /// Deterministic-ish signing key for tests — derives 32 bytes from + /// a seed byte so each test can hold its own without pulling in + /// `rand` as a dev-dep. Any value 1..=127 works (P-256 scalar + /// must be non-zero and < curve order); we spread bytes over the + /// buffer so keys with adjacent seeds don't share high bits. + fn test_key(seed: u8) -> SigningKey { + let mut bytes = [0u8; 32]; + for (i, b) in bytes.iter_mut().enumerate() { + *b = seed.wrapping_add(i as u8).wrapping_add(1); + } + SigningKey::from_bytes(&bytes.into()).expect("valid P-256 scalar") + } + + /// Build a signed DPoP proof for testing — mirrors what + /// `frontend/src/lib/auth/dpop-proof.ts` produces on the client. + #[allow(clippy::too_many_arguments)] + fn make_proof( + signing_key: &SigningKey, + htm: &str, + htu: &str, + iat: i64, + jti: &str, + nonce: Option<&str>, + override_alg: Option<&str>, + override_typ: Option<&str>, + ) -> (String, String) { + let vkey = signing_key.verifying_key(); + let encoded = vkey.to_encoded_point(false); // uncompressed + let x = encoded.x().unwrap(); + let y = encoded.y().unwrap(); + let x_b64 = B64_URL_NO_PAD.encode(x); + let y_b64 = B64_URL_NO_PAD.encode(y); + + let header = serde_json::json!({ + "typ": override_typ.unwrap_or("dpop+jwt"), + "alg": override_alg.unwrap_or("ES256"), + "jwk": { "crv": "P-256", "kty": "EC", "x": x_b64, "y": y_b64 }, + }); + let mut claims = serde_json::json!({ + "htm": htm, + "htu": htu, + "iat": iat, + "jti": jti, + }); + if let Some(n) = nonce { + claims.as_object_mut().unwrap().insert( + "nonce".to_string(), + serde_json::Value::String(n.to_string()), + ); + } + + let h_b64 = B64_URL_NO_PAD.encode(header.to_string()); + let p_b64 = B64_URL_NO_PAD.encode(claims.to_string()); + let signing_input = format!("{h_b64}.{p_b64}"); + let sig: Signature = signing_key.sign(signing_input.as_bytes()); + let s_b64 = B64_URL_NO_PAD.encode(sig.to_bytes()); + let proof = format!("{h_b64}.{p_b64}.{s_b64}"); + + // Compute canonical thumbprint the same way verify() does + let canonical = format!(r#"{{"crv":"P-256","kty":"EC","x":"{x_b64}","y":"{y_b64}"}}"#,); + let jkt = B64_URL_NO_PAD.encode(Sha256::digest(canonical.as_bytes())); + + (proof, jkt) + } + + fn ctx<'a>( + htm: &'a str, + htu: &'a str, + now: i64, + expected_jkt: Option<&'a str>, + ) -> DpopRequestContext<'a> { + DpopRequestContext { + htm, + htu, + now_secs: now, + expected_jkt, + } + } + + #[test] + fn accepts_happy_path() { + let sk = test_key(1); + let (proof, jkt) = make_proof( + &sk, + "GET", + "https://oxi.example/api/me", + 1_000_000, + "jti-1", + None, + None, + None, + ); + let out = verify( + &proof, + &ctx("GET", "https://oxi.example/api/me", 1_000_000, Some(&jkt)), + ) + .unwrap(); + assert_eq!(out.jkt, jkt); + assert_eq!(out.jti, "jti-1"); + assert_eq!(out.nonce, None); + } + + #[test] + fn rejects_wrong_htm() { + let sk = test_key(1); + let (proof, jkt) = make_proof(&sk, "POST", "https://x/a", 1_000_000, "j", None, None, None); + let err = verify(&proof, &ctx("GET", "https://x/a", 1_000_000, Some(&jkt))).unwrap_err(); + assert_eq!(err, DpopVerifyError::WrongHtm); + assert_eq!(err.reason(), "wrong_htm"); + } + + #[test] + fn rejects_wrong_htu() { + let sk = test_key(1); + let (proof, jkt) = make_proof(&sk, "GET", "https://x/a", 1_000_000, "j", None, None, None); + let err = verify(&proof, &ctx("GET", "https://x/b", 1_000_000, Some(&jkt))).unwrap_err(); + assert_eq!(err, DpopVerifyError::WrongHtu); + } + + #[test] + fn rejects_wrong_alg() { + let sk = test_key(1); + let (proof, _jkt) = make_proof( + &sk, + "GET", + "https://x/a", + 1_000_000, + "j", + None, + Some("RS256"), + None, + ); + // Wrong alg reject fires BEFORE signature verify (alg is a + // header field we check first). No expected_jkt needed — + // we don't get that far. + let err = verify(&proof, &ctx("GET", "https://x/a", 1_000_000, None)).unwrap_err(); + assert_eq!(err, DpopVerifyError::WrongAlg); + } + + #[test] + fn rejects_wrong_typ() { + let sk = test_key(1); + let (proof, _jkt) = make_proof( + &sk, + "GET", + "https://x/a", + 1_000_000, + "j", + None, + None, + Some("jwt"), + ); + let err = verify(&proof, &ctx("GET", "https://x/a", 1_000_000, None)).unwrap_err(); + assert_eq!(err, DpopVerifyError::WrongTyp); + } + + #[test] + fn rejects_expired_iat_when_no_nonce() { + let sk = test_key(1); + let (proof, jkt) = make_proof(&sk, "GET", "https://x/a", 1_000_000, "j", None, None, None); + // now = iat + 60 → outside ±30s tolerance + let err = verify(&proof, &ctx("GET", "https://x/a", 1_000_060, Some(&jkt))).unwrap_err(); + assert_eq!(err, DpopVerifyError::IatOutOfWindow); + } + + #[test] + fn accepts_stale_iat_when_nonce_present() { + let sk = test_key(1); + let (proof, jkt) = make_proof( + &sk, + "GET", + "https://x/a", + 1_000_000, + "j", + Some("srv-nonce"), + None, + None, + ); + // now = iat + 10 minutes → would fail bootstrap check, but + // nonce is present → freshness check delegated to nonce + // validity (Gate 5b), so we accept here. + let out = verify(&proof, &ctx("GET", "https://x/a", 1_000_600, Some(&jkt))).unwrap(); + assert_eq!(out.nonce.as_deref(), Some("srv-nonce")); + } + + #[test] + fn rejects_jkt_mismatch() { + let sk = test_key(1); + let (proof, _jkt) = make_proof(&sk, "GET", "https://x/a", 1_000_000, "j", None, None, None); + let err = verify( + &proof, + &ctx( + "GET", + "https://x/a", + 1_000_000, + Some("some-other-thumbprint-value"), + ), + ) + .unwrap_err(); + assert_eq!(err, DpopVerifyError::JktMismatch); + } + + #[test] + fn rejects_bad_signature_when_payload_tampered() { + let sk = test_key(1); + let (proof, jkt) = make_proof(&sk, "GET", "https://x/a", 1_000_000, "j", None, None, None); + // Corrupt the middle segment (payload) — signature will no + // longer verify against the tampered signing-input bytes. + let mut parts: Vec<&str> = proof.split('.').collect(); + parts[1] = "bm90LWEtcmVhbC1wYXlsb2Fk"; // "not-a-real-payload" + let tampered = parts.join("."); + let err = verify(&tampered, &ctx("GET", "https://x/a", 1_000_000, Some(&jkt))).unwrap_err(); + assert_eq!(err, DpopVerifyError::SignatureInvalid); + } + + #[test] + fn rejects_malformed_jws() { + // Only 2 segments + let err = verify("aa.bb", &ctx("GET", "https://x/a", 0, None)).unwrap_err(); + assert_eq!(err, DpopVerifyError::Malformed); + // 4 segments + let err = verify("a.b.c.d", &ctx("GET", "https://x/a", 0, None)).unwrap_err(); + assert_eq!(err, DpopVerifyError::Malformed); + // Bad base64 + let err = verify("!!.??.@@", &ctx("GET", "https://x/a", 0, None)).unwrap_err(); + assert_eq!(err, DpopVerifyError::Malformed); + } + + #[test] + fn missing_jti_is_rejected() { + // Build a proof with an empty jti — verify() rejects because + // it's essential for the replay cache to key on. + let sk = test_key(1); + let (proof, jkt) = make_proof(&sk, "GET", "https://x/a", 1_000_000, "", None, None, None); + let err = verify(&proof, &ctx("GET", "https://x/a", 1_000_000, Some(&jkt))).unwrap_err(); + assert_eq!(err, DpopVerifyError::JtiMissing); + } +} diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index e912d60f..f1e96a52 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -10,6 +10,8 @@ pub mod compression_service; pub mod consistency_batch_service; pub mod db_pool_monitor; pub mod dedup_service; +pub mod dpop_nonce_service; +pub mod dpop_verifier; pub mod drives_consistency_service; pub mod encrypted_blob_backend; pub mod entry_backend; diff --git a/src/interfaces/middleware/dpop.rs b/src/interfaces/middleware/dpop.rs new file mode 100644 index 00000000..45422d0a --- /dev/null +++ b/src/interfaces/middleware/dpop.rs @@ -0,0 +1,302 @@ +//! DPoP proof enforcement middleware (RFC 9449). +//! +//! Runs AFTER the auth middleware — reads the `CurrentUser` +//! extension to know the caller has an authenticated session, and +//! validates the `DPoP` header against the session's stored JWK +//! thumbprint (`session.dpop_jkt`). +//! +//! Mode dispatch (from `OXICLOUD_DPOP_MODE`): +//! * **Off** — pass-through, no work. Safe default. +//! * **Opportunistic** — verify when present, allow when absent. +//! Rollout mode: catches client bugs before enforcement. +//! * **Required** — bound sessions MUST present a valid proof. +//! Unbound sessions (`dpop_jkt IS NULL`) remain exempt (app +//! passwords, legacy). +//! +//! Failure response shape mirrors RFC 9449 §7.1: +//! * generic bad proof → `401` + `WWW-Authenticate: DPoP +//! error="invalid_dpop_proof"` +//! * nonce missing / stale (Gate 5b) → `401` + +//! `WWW-Authenticate: DPoP error="use_dpop_nonce"` + +//! `DPoP-Nonce: ` — client retries once, transparently. +//! +//! Body is JSON `{"error_type": "DpopVerificationFailed"}` in both +//! cases (anti-enumeration: same shape regardless of reason; the +//! audit line carries the machine-readable reason). +//! +//! Every response — success OR failure — also gets a `DPoP-Nonce` +//! header pointing at the currently-fresh server-issued nonce. +//! Clients cache it; the next request presents it and skips the +//! challenge round trip. +//! +//! Replay detection (jti-per-nonce) is Gate 6; not wired yet. + +use axum::extract::{OriginalUri, Request, State}; +use axum::http::{HeaderMap, HeaderValue, StatusCode}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use std::sync::Arc; + +use crate::common::config::DpopMode; +use crate::common::di::AppState; +use crate::infrastructure::services::dpop_verifier::{ + DpopRequestContext, DpopVerifyError, verify as verify_proof, +}; +use crate::interfaces::errors::AppError; +use crate::interfaces::middleware::auth::CurrentUser; + +/// Resolve the request's external `(scheme, host)` — what the client +/// sees the URL as, which is what its DPoP proof's `htu` was built +/// from. Behind a reverse proxy, the internal request scheme + +/// authority differ from the external ones; without normalising here +/// the verifier fires `wrong_htu` on every request. +/// +/// Priority chain (RFC 7239-adjacent — mirror what oxicloud audit +/// spans use for `client_ip`): +/// 1. `X-Forwarded-Proto` + `X-Forwarded-Host` +/// 2. `Host` header with scheme inferred from `is_https` request +/// 3. Fallback (`http` + `localhost`) — dev-only, unrepresentative +/// +/// NB: no trust-boundary check here. If your deployment lets +/// arbitrary clients set `X-Forwarded-*`, they can already forge +/// audit-log IPs everywhere else — that's an operator responsibility +/// solved by the trusted-proxy config, not this helper. +fn external_scheme_host(headers: &HeaderMap) -> (String, String) { + let scheme = headers + .get("x-forwarded-proto") + .and_then(|v| v.to_str().ok()) + .map(|s| s.split(',').next().unwrap_or(s).trim().to_owned()) + .unwrap_or_else(|| "http".to_owned()); + let host = headers + .get("x-forwarded-host") + .or_else(|| headers.get("host")) + .and_then(|v| v.to_str().ok()) + .map(|s| s.split(',').next().unwrap_or(s).trim().to_owned()) + .unwrap_or_else(|| "localhost".to_owned()); + (scheme, host) +} + +/// Middleware entry point mounted on authenticated `/api/*` subtrees. +pub async fn require_dpop_layer( + State(state): State>, + request: Request, + next: Next, +) -> Response { + let mode = state.core.config.auth.dpop_mode; + if mode == DpopMode::Off { + return next.run(request).await; + } + let nonce_service = state.dpop_nonce_service.clone(); + + // No authenticated user → pass through (upstream auth layer + // already handled or will handle the 401). We only concern + // ourselves with proof-carrying requests on authenticated paths. + let Some(_current_user) = request.extensions().get::>() else { + return next.run(request).await; + }; + + let dpop_header = request + .headers() + .get("DPoP") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + + // GATE 5 SCOPE: session-level `dpop_jkt` lookup is deferred (see + // module docstring). For opportunistic mode: + // - proof present → verify with expected_jkt=None (accept any + // jkt shape; still catches malformed/bad-sig/wrong-htu bugs) + // - proof absent → pass through, no warning yet + // For required mode: same for now — Gate 9 flips to real + // per-session enforcement once session context is wired. + let Some(proof) = dpop_header else { + let response = next.run(request).await; + return stamp_current_nonce(response, &nonce_service); + }; + + // Build canonical htu — external scheme + host (`X-Forwarded-*` + // aware) + OriginalUri path (nest-strip-safe). Query stripped + // per RFC 9449 §4.2. + let (scheme, host) = external_scheme_host(request.headers()); + let path = request + .extensions() + .get::() + .map(|u| u.0.path().to_owned()) + .unwrap_or_else(|| request.uri().path().to_owned()); + let htu = format!("{scheme}://{host}{path}"); + + let method = request.method().as_str().to_owned(); + let now_secs = chrono::Utc::now().timestamp(); + + let ctx = DpopRequestContext { + htm: &method, + htu: &htu, + now_secs, + expected_jkt: None, // Session-level pin comes in a later gate + }; + match verify_proof(&proof, &ctx) { + Ok(verified) => { + // Nonce validation: the verifier extracted the claim; if + // present, it MUST be in our live pool. Absent → OK on + // the bootstrap request, but the challenge below MUST + // still fire so the very next request carries a nonce. + match verified.nonce.as_deref() { + Some(n) if !nonce_service.is_valid(n) => { + tracing::info!( + target: "audit", + event = "dpop.verify_failed", + reason = "nonce_stale", + method = %method, + htu = %htu, + "👮🏻‍♂️ DPoP nonce stale — issuing challenge", + ); + return nonce_challenge_response(&nonce_service); + } + None => { + // No nonce presented at all → challenge so the + // NEXT request carries one. The ±30s bootstrap + // window at the verifier means this one still + // succeeded, but we still want the client onto + // the nonce path immediately. + return nonce_challenge_response(&nonce_service); + } + _ => {} + } + let response = next.run(request).await; + stamp_current_nonce(response, &nonce_service) + } + Err(err) => { + tracing::info!( + target: "audit", + event = "dpop.verify_failed", + reason = err.reason(), + method = %method, + htu = %htu, + "👮🏻‍♂️ DPoP proof rejected", + ); + dpop_verification_failed_response(err, &nonce_service) + } + } +} + +/// Stamp the currently-fresh nonce onto the outgoing response so +/// the client sees it and caches it for its next request. Called +/// on EVERY successful passthrough — the client's fetch interceptor +/// keeps its cached nonce in sync automatically. +fn stamp_current_nonce( + mut response: Response, + nonce_service: &crate::infrastructure::services::dpop_nonce_service::DpopNonceService, +) -> Response { + let fresh = nonce_service.current_or_rotate(); + if let Ok(hv) = HeaderValue::from_str(&fresh) { + response.headers_mut().insert("DPoP-Nonce", hv); + } + response +} + +/// Build a `use_dpop_nonce` challenge response — 401 + +/// WWW-Authenticate + DPoP-Nonce carrying a fresh nonce. The SPA +/// fetch interceptor (Gate 4) auto-retries once with the new nonce +/// so users don't experience a visible failure. +fn nonce_challenge_response( + nonce_service: &crate::infrastructure::services::dpop_nonce_service::DpopNonceService, +) -> Response { + let mut resp = AppError::new( + StatusCode::UNAUTHORIZED, + "DPoP nonce required", + "DpopVerificationFailed", + ) + .into_response(); + resp.headers_mut().insert( + "WWW-Authenticate", + HeaderValue::from_static(r#"DPoP error="use_dpop_nonce""#), + ); + let fresh = nonce_service.current_or_rotate(); + if let Ok(hv) = HeaderValue::from_str(&fresh) { + resp.headers_mut().insert("DPoP-Nonce", hv); + } + resp +} + +/// Build the standardised 401 response for a rejected DPoP proof. +/// Response shape: RFC 9449 §7.1 `WWW-Authenticate: DPoP error="…"` +/// plus OxiCloud's `error_type` JSON body so the SPA can key off it. +/// Also carries a fresh `DPoP-Nonce` so a client whose failure was +/// nonce-shaped (rare after this refactor, but future error paths +/// might need it) can retry immediately. +fn dpop_verification_failed_response( + err: DpopVerifyError, + nonce_service: &crate::infrastructure::services::dpop_nonce_service::DpopNonceService, +) -> Response { + let mut resp = AppError::new( + StatusCode::UNAUTHORIZED, + "DPoP proof verification failed", + "DpopVerificationFailed", + ) + .into_response(); + // Static: `WWW-Authenticate` schemes stay stable across errors. + // Only the audit `reason` field varies (already emitted). + let www_auth = HeaderValue::from_static(r#"DPoP error="invalid_dpop_proof""#); + resp.headers_mut().insert("WWW-Authenticate", www_auth); + let fresh = nonce_service.current_or_rotate(); + if let Ok(hv) = HeaderValue::from_str(&fresh) { + resp.headers_mut().insert("DPoP-Nonce", hv); + } + // Silence unused-parameter lint — err is captured in the audit + // line at the callsite; this fn intentionally maps ALL failures + // to the same client-facing shape (anti-enumeration). + let _ = err; + resp +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::HeaderMap; + + #[test] + fn external_scheme_host_prefers_forwarded_headers() { + let mut h = HeaderMap::new(); + h.insert("x-forwarded-proto", HeaderValue::from_static("https")); + h.insert("x-forwarded-host", HeaderValue::from_static("oxi.example")); + h.insert("host", HeaderValue::from_static("internal:8086")); + assert_eq!( + external_scheme_host(&h), + ("https".to_owned(), "oxi.example".to_owned()) + ); + } + + #[test] + fn external_scheme_host_falls_back_to_host_header() { + let mut h = HeaderMap::new(); + h.insert("host", HeaderValue::from_static("localhost:5173")); + assert_eq!( + external_scheme_host(&h), + ("http".to_owned(), "localhost:5173".to_owned()) + ); + } + + #[test] + fn external_scheme_host_takes_leftmost_of_forwarded_chain() { + // Multiple hops → `X-Forwarded-*` becomes a comma-separated + // list. RFC 7239 says the leftmost is the original client. + let mut h = HeaderMap::new(); + h.insert("x-forwarded-proto", HeaderValue::from_static("https, http")); + h.insert( + "x-forwarded-host", + HeaderValue::from_static("oxi.example, internal"), + ); + assert_eq!( + external_scheme_host(&h), + ("https".to_owned(), "oxi.example".to_owned()) + ); + } + + #[test] + fn external_scheme_host_defaults_when_empty() { + let h = HeaderMap::new(); + assert_eq!( + external_scheme_host(&h), + ("http".to_owned(), "localhost".to_owned()) + ); + } +} diff --git a/src/interfaces/middleware/mod.rs b/src/interfaces/middleware/mod.rs index 8dbe66e3..6d4de627 100644 --- a/src/interfaces/middleware/mod.rs +++ b/src/interfaces/middleware/mod.rs @@ -1,6 +1,7 @@ pub mod admin; pub mod auth; pub mod csrf; +pub mod dpop; pub mod locale; pub mod rate_limit; pub mod server_status; From 5c4354bc6794f466cf06fa5eaa037eaf6b82b007 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sat, 8 Aug 2026 15:55:08 +0200 Subject: [PATCH 06/51] feat(DPoP): add anti replay cache --- src/common/di.rs | 7 + .../services/dpop_nonce_service.rs | 184 ++++++++++++++++++ .../services/dpop_replay_cache.rs | 107 ++++++++++ src/infrastructure/services/mod.rs | 1 + src/interfaces/middleware/dpop.rs | 32 ++- 5 files changed, 328 insertions(+), 3 deletions(-) create mode 100644 src/infrastructure/services/dpop_nonce_service.rs create mode 100644 src/infrastructure/services/dpop_replay_cache.rs diff --git a/src/common/di.rs b/src/common/di.rs index f16ded1a..9e89ab0a 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -2105,6 +2105,9 @@ impl AppServiceFactory { dpop_nonce_service: Arc::new( crate::infrastructure::services::dpop_nonce_service::DpopNonceService::new(), ), + dpop_replay_cache: Arc::new( + crate::infrastructure::services::dpop_replay_cache::DpopReplayCache::new(), + ), nextcloud: nextcloud_services, admin_settings_service: None, storage_settings_service: None, @@ -2883,6 +2886,10 @@ pub struct AppState { /// pay only allocation cost at boot. pub dpop_nonce_service: Arc, + /// DPoP replay cache — nonce-scoped `jti` dedup. Same lifecycle + /// as `dpop_nonce_service` (always populated, cheap at boot). + pub dpop_replay_cache: + Arc, pub nextcloud: Option, pub admin_settings_service: Option>, /// WASM plugin management (list/install/toggle/remove), backing the admin diff --git a/src/infrastructure/services/dpop_nonce_service.rs b/src/infrastructure/services/dpop_nonce_service.rs new file mode 100644 index 00000000..38448964 --- /dev/null +++ b/src/infrastructure/services/dpop_nonce_service.rs @@ -0,0 +1,184 @@ +//! DPoP-Nonce service (RFC 9449 §8) — issues and validates the +//! server-generated nonces that eliminate reliance on the client +//! clock for freshness. +//! +//! Model — a **pool of currently-valid nonces**, not a single "last" +//! value. Every nonce we hand out sits in the pool for its full +//! lifetime; multiple can be simultaneously valid (rotation overlap, +//! multi-tab). A proof's `nonce` claim is valid iff the pool still +//! remembers it. +//! +//! Rotation — every request response can carry a `DPoP-Nonce` +//! header pointing at the "current" nonce. When the current nonce +//! is older than [`ROTATION_INTERVAL`], `current_or_rotate` mints +//! a fresh one and returns it (the outgoing one keeps living in +//! the pool until its TTL expires — the overlap window). Clients +//! opportunistically pick up the fresh header and start using it; +//! in-flight requests carrying the previous nonce remain valid +//! throughout the overlap. +//! +//! Storage — in-memory `moka` LRU, no PG persistence. On server +//! restart the pool is empty → every next client request gets a +//! `use_dpop_nonce` challenge (middleware handles this) which +//! transparently rotates the client onto a fresh nonce. That's +//! why the SPA fetch interceptor (Gate 4) has a mandatory +//! challenge-retry loop. +//! +//! Scale — a hard cap on cache size bounds memory under attack. +//! At ~64 bytes/entry and a 100k cap, worst-case ~6 MB. Under +//! normal traffic the pool is far below the cap. +//! +//! **Multi-instance caveat**: each OxiCloud replica has its own +//! pool. A nonce issued by node A + validated by node B will 401 → +//! challenge → retry → one extra round trip, no security impact. +//! Elevate to a shared Redis if the operational impact ever +//! matters; for the common single-instance self-hosted deployment +//! in-memory is correct. + +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64_URL_NO_PAD; +use moka::sync::Cache; +use std::sync::RwLock; +use std::time::{Duration, Instant}; + +/// How long a nonce is valid after issuance. Rejected outright once +/// past this window (moka TTL enforces it — no manual sweep needed). +pub const NONCE_LIFETIME: Duration = Duration::from_secs(300); // 5 min + +/// Once the "current" nonce is older than this, `current_or_rotate` +/// mints a fresh one on next call. The outgoing nonce stays valid +/// in the pool until its own TTL expires, giving a 3-minute overlap +/// window during which both work. Clients pick up the fresh header +/// in the next response and switch over lazily. +pub const ROTATION_INTERVAL: Duration = Duration::from_secs(120); // 2 min + +/// Max nonce entries — bounds memory under attack. LRU eviction +/// past this cap. +const MAX_POOL_SIZE: u64 = 100_000; + +/// Byte length of a nonce before base64url encoding. 32 bytes → +/// 43-char b64url string, matching JWK-thumbprint dimensions so +/// operator eyes calibrate the same way for both fields. +const NONCE_BYTES: usize = 32; + +/// Currently-active pool of nonces + the freshest one served in +/// `DPoP-Nonce` response headers. +pub struct DpopNonceService { + /// Pool of live nonces. Value is `()` — presence == validity; + /// TTL enforced by moka's `time_to_live`. + pool: Cache, + /// Freshest nonce we've issued + when — used to decide when to + /// rotate. `None` at boot, populated on first `current_or_rotate`. + current: RwLock>, +} + +struct CurrentNonce { + value: String, + issued_at: Instant, +} + +impl Default for DpopNonceService { + fn default() -> Self { + Self::new() + } +} + +impl DpopNonceService { + pub fn new() -> Self { + Self { + pool: Cache::builder() + .max_capacity(MAX_POOL_SIZE) + .time_to_live(NONCE_LIFETIME) + .build(), + current: RwLock::new(None), + } + } + + /// Return the current nonce, minting a fresh one when the last + /// mint is older than [`ROTATION_INTERVAL`] (or on cold start). + /// The returned value is what the middleware stamps into + /// outgoing `DPoP-Nonce` response headers. + pub fn current_or_rotate(&self) -> String { + // Fast path: read lock, current is still fresh → clone the string. + if let Some(cur) = self.current.read().unwrap().as_ref() + && cur.issued_at.elapsed() < ROTATION_INTERVAL + { + return cur.value.clone(); + } + // Slow path: write lock, re-check (someone else may have + // rotated between drop-read and acquire-write), otherwise + // mint fresh. + let mut guard = self.current.write().unwrap(); + if let Some(cur) = guard.as_ref() + && cur.issued_at.elapsed() < ROTATION_INTERVAL + { + return cur.value.clone(); + } + let fresh = mint_nonce(); + self.pool.insert(fresh.clone(), ()); + *guard = Some(CurrentNonce { + value: fresh.clone(), + issued_at: Instant::now(), + }); + fresh + } + + /// Check whether a nonce presented by a client is still valid. + /// Returns `false` for absent-from-pool AND for + /// past-TTL-eviction; both are indistinguishable from the + /// caller's perspective. + pub fn is_valid(&self, nonce: &str) -> bool { + self.pool.contains_key(nonce) + } +} + +fn mint_nonce() -> String { + // Re-use `p256`'s already-transitive `rand_core::OsRng` — no new + // dep, no version alignment risk. `OsRng` reads from the OS + // entropy source; `fill_bytes` panics on RNG failure (unreachable + // outside catastrophic OS state, and safer to crash than mint a + // guessable nonce). + use p256::elliptic_curve::rand_core::{OsRng, RngCore}; + let mut buf = [0u8; NONCE_BYTES]; + OsRng.fill_bytes(&mut buf); + B64_URL_NO_PAD.encode(buf) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn issues_a_nonce_of_expected_shape() { + let svc = DpopNonceService::new(); + let n = svc.current_or_rotate(); + // 43 chars = base64url(SHA-256-equivalent length) + assert_eq!(n.len(), 43); + assert!( + n.bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'), + "nonce contains non-base64url chars: {n}" + ); + } + + #[test] + fn issued_nonce_validates_immediately() { + let svc = DpopNonceService::new(); + let n = svc.current_or_rotate(); + assert!(svc.is_valid(&n)); + } + + #[test] + fn returns_same_nonce_within_rotation_window() { + let svc = DpopNonceService::new(); + let a = svc.current_or_rotate(); + let b = svc.current_or_rotate(); + assert_eq!(a, b); + } + + #[test] + fn unknown_nonce_is_rejected() { + let svc = DpopNonceService::new(); + assert!(!svc.is_valid("not-a-real-nonce-value-1234567890abcde")); + } +} diff --git a/src/infrastructure/services/dpop_replay_cache.rs b/src/infrastructure/services/dpop_replay_cache.rs new file mode 100644 index 00000000..a8a1393d --- /dev/null +++ b/src/infrastructure/services/dpop_replay_cache.rs @@ -0,0 +1,107 @@ +//! DPoP replay cache — remembers `(nonce, jti)` tuples we've +//! already verified, and rejects duplicates as replays. +//! +//! Nonce-scoped by design (see `docs/plan/dpop.md` Gate 6). A `jti` +//! is only guaranteed unique WITHIN the lifetime of a nonce; a +//! naive global-`jti` cache would falsely reject the second use of +//! a `jti` value the client happened to reuse across two nonces +//! (statistically negligible for 128-bit UUIDs but semantically +//! wrong per the spec). +//! +//! Two-scope invariant, tested below: +//! * same `jti` under DIFFERENT nonces → both accepted +//! * same `(nonce, jti)` seen twice → second is a replay +//! +//! TTL is aligned with [`super::dpop_nonce_service::NONCE_LIFETIME`] +//! (5 minutes) — once a nonce ages out of the nonce pool it cannot +//! validate anyway, so replay-cache entries against that nonce are +//! moot the moment the outer freshness check fires. Both caches +//! bounded at ~100k entries. + +use moka::sync::Cache; +use std::time::Duration; + +/// Same as `NONCE_LIFETIME` — see file doc. +const REPLAY_ENTRY_TTL: Duration = Duration::from_secs(300); + +/// Cap. Roughly 100 bytes/entry (two short strings + moka +/// bookkeeping) → ~10 MB ceiling under sustained attack, per plan. +const MAX_ENTRIES: u64 = 100_000; + +/// In-memory nonce-scoped replay tracker. +pub struct DpopReplayCache { + seen: Cache<(String, String), ()>, +} + +impl Default for DpopReplayCache { + fn default() -> Self { + Self::new() + } +} + +impl DpopReplayCache { + pub fn new() -> Self { + Self { + seen: Cache::builder() + .max_capacity(MAX_ENTRIES) + .time_to_live(REPLAY_ENTRY_TTL) + .build(), + } + } + + /// Record a fresh `(nonce, jti)` pair, returning `true` if this + /// is the first time we've seen it (accept the proof) and + /// `false` if we've already recorded it (replay — reject). + /// + /// Uses moka's atomic `entry` API so two racing verify calls + /// for the same `(nonce, jti)` — the pathological concurrent- + /// replay window — resolve to exactly one `true` and one + /// `false`, never both `true`. + pub fn check_and_record(&self, nonce: &str, jti: &str) -> bool { + let key = (nonce.to_string(), jti.to_string()); + // `entry().or_insert_with(...)` is atomic across concurrent + // callers; the returned `Entry` exposes `is_fresh()` to + // distinguish "we just wrote this" from "already existed". + let entry = self.seen.entry(key).or_insert_with(|| ()); + entry.is_fresh() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn first_seen_is_accepted() { + let c = DpopReplayCache::new(); + assert!(c.check_and_record("nonce-A", "jti-1")); + } + + #[test] + fn duplicate_same_scope_is_replay() { + let c = DpopReplayCache::new(); + assert!(c.check_and_record("nonce-A", "jti-1")); + assert!( + !c.check_and_record("nonce-A", "jti-1"), + "second insert of same (nonce, jti) must be flagged as replay" + ); + } + + #[test] + fn same_jti_different_nonces_both_accepted() { + // Nonce-scoped invariant: `jti` uniqueness is only meaningful + // within a single nonce lifetime. Reusing a `jti` across + // different nonces is legitimate (the second nonce is a + // fresh replay scope) and MUST NOT trip replay detection. + let c = DpopReplayCache::new(); + assert!(c.check_and_record("nonce-A", "jti-1")); + assert!(c.check_and_record("nonce-B", "jti-1")); + } + + #[test] + fn different_jtis_same_nonce_both_accepted() { + let c = DpopReplayCache::new(); + assert!(c.check_and_record("nonce-A", "jti-1")); + assert!(c.check_and_record("nonce-A", "jti-2")); + } +} diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index f1e96a52..62f2e0af 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -11,6 +11,7 @@ pub mod consistency_batch_service; pub mod db_pool_monitor; pub mod dedup_service; pub mod dpop_nonce_service; +pub mod dpop_replay_cache; pub mod dpop_verifier; pub mod drives_consistency_service; pub mod encrypted_blob_backend; diff --git a/src/interfaces/middleware/dpop.rs b/src/interfaces/middleware/dpop.rs index 45422d0a..f975070a 100644 --- a/src/interfaces/middleware/dpop.rs +++ b/src/interfaces/middleware/dpop.rs @@ -29,7 +29,11 @@ //! Clients cache it; the next request presents it and skips the //! challenge round trip. //! -//! Replay detection (jti-per-nonce) is Gate 6; not wired yet. +//! Replay detection: after nonce validation succeeds, the +//! `(nonce, jti)` pair is recorded in a moka LRU. A second proof +//! carrying the same `(nonce, jti)` — the classic replay window — +//! fires `dpop.replay_detected` and returns 401 with the standard +//! `invalid_dpop_proof` error shape. use axum::extract::{OriginalUri, Request, State}; use axum::http::{HeaderMap, HeaderValue, StatusCode}; @@ -87,6 +91,7 @@ pub async fn require_dpop_layer( return next.run(request).await; } let nonce_service = state.dpop_nonce_service.clone(); + let replay_cache = state.dpop_replay_cache.clone(); // No authenticated user → pass through (upstream auth layer // already handled or will handle the 401). We only concern @@ -139,7 +144,7 @@ pub async fn require_dpop_layer( // present, it MUST be in our live pool. Absent → OK on // the bootstrap request, but the challenge below MUST // still fire so the very next request carries a nonce. - match verified.nonce.as_deref() { + let live_nonce = match verified.nonce.as_deref() { Some(n) if !nonce_service.is_valid(n) => { tracing::info!( target: "audit", @@ -159,8 +164,29 @@ pub async fn require_dpop_layer( // the nonce path immediately. return nonce_challenge_response(&nonce_service); } - _ => {} + Some(n) => n, + }; + + // Replay guard — nonce-scoped `jti` dedup. Runs AFTER + // nonce validity so we don't populate the cache with + // entries against a nonce that would 401 anyway (waste + // of pool space; also lets an attacker probe expired + // nonces without pressuring the cache). + if !replay_cache.check_and_record(live_nonce, &verified.jti) { + tracing::info!( + target: "audit", + event = "dpop.replay_detected", + method = %method, + htu = %htu, + jti = %verified.jti, + "👮🏻‍♂️ DPoP proof replayed — same (nonce, jti) seen twice", + ); + return dpop_verification_failed_response( + DpopVerifyError::SignatureInvalid, // shape-only; audit line carries truth + &nonce_service, + ); } + let response = next.run(request).await; stamp_current_nonce(response, &nonce_service) } From 811c7b0f12b0be85b364d361ad1eb97e8c575c88 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sat, 8 Aug 2026 16:21:19 +0200 Subject: [PATCH 07/51] feat(DPoP): add API test --- Cargo.toml | 9 + src/bin/dpop-hurl-helper.rs | 689 ++++++++++++++++++++++++++++++++++++ src/main.rs | 33 ++ tests/api/run.sh | 24 ++ tests/common/server.env | 11 + 5 files changed, 766 insertions(+) create mode 100644 src/bin/dpop-hurl-helper.rs diff --git a/Cargo.toml b/Cargo.toml index 9806461b..37d81fe7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -200,6 +200,15 @@ path = "src/bin/opaque-hurl-helper.rs" # blinding, AKE nonces are per-attempt-random). Not shipped in the # release Dockerfile (nothing outside tests/ calls it). +[[bin]] +name = "dpop-hurl-helper" +path = "src/bin/dpop-hurl-helper.rs" +# Test-suite DPoP client — signs an ES256 proof per request, threads +# the DPoP-Nonce challenge/retry loop, and covers the wire-protocol +# scenarios Hurl can't express (per-request fresh jti/iat, replay +# detection, malformed proofs, wrong htm/htu/alg/typ). Same +# no-ship-in-release status as opaque-hurl-helper. + [[bin]] name = "load-seed" path = "src/bin/load-seed.rs" diff --git a/src/bin/dpop-hurl-helper.rs b/src/bin/dpop-hurl-helper.rs new file mode 100644 index 00000000..3a0ed0d8 --- /dev/null +++ b/src/bin/dpop-hurl-helper.rs @@ -0,0 +1,689 @@ +//! DPoP wire-protocol test helper for the api-test suite. +//! +//! Hurl can't drive DPoP: every proof carries a fresh `jti`, a +//! current `iat`, an `htm`/`htu` matching the exact request, an +//! ES256 signature from a persistent browser-held keypair, and a +//! nonce threaded from the server's `DPoP-Nonce` response header. +//! A declarative `.hurl` template has no way to compute that per +//! request. Same problem OPAQUE has, same solution: +//! `opaque-hurl-helper.rs` (task #19) verifies OPAQUE end-to-end; +//! this binary does the same for DPoP. +//! +//! Invocation (from `tests/api/run.sh`): +//! +//! ```bash +//! OXICLOUD_DPOP_MODE=required +//! DPOP_HELPER_BASE_URL=$base_url \ +//! DPOP_HELPER_USERNAME=$username \ +//! DPOP_HELPER_PASSWORD=$password \ +//! ./target/debug/dpop-hurl-helper +//! ``` +//! +//! Exit codes: +//! * 0 — every scenario succeeded. +//! * 1 — any scenario failed; diagnostic on stderr. +//! +//! Scope: this binary covers the wire contract for the currently- +//! integrated slice — verifier + nonce + replay + middleware in +//! opportunistic mode. Scenarios requiring session-context binding +//! (bound-vs-unbound enforcement per `session.dpop_jkt`, refresh +//! continuity, thumbprint mismatch vs stored) are deferred until +//! Gate 7 threads session context through the middleware. + +use base64::Engine as _; +use base64::engine::general_purpose::{STANDARD as B64, URL_SAFE_NO_PAD as B64_URL_NO_PAD}; +use opaque_ke::{ClientLogin, ClientLoginFinishParameters, CredentialResponse}; +use p256::ecdsa::signature::Signer; +use p256::ecdsa::{Signature, SigningKey}; +use rand_core::OsRng as OpaqueRng; +use serde_json::json; +use sha2::{Digest, Sha256}; +use std::process::ExitCode; + +// Reuse the concrete ciphersuite the production `OpaqueService` uses — +// mismatching client + server suites would fail every handshake with +// a confusing error. +use oxicloud::infrastructure::services::opaque_service::OxiCloudSuite; + +/// Server may emit URL_SAFE_NO_PAD *or* STANDARD base64; try both so +/// a future format flip doesn't silently break the round-trip. +fn decode_opaque_b64(input: &str) -> Result, base64::DecodeError> { + let s = input.trim(); + B64_URL_NO_PAD.decode(s).or_else(|_| B64.decode(s)) +} + +#[derive(serde::Deserialize)] +struct OpaqueParamsResp { + enabled: bool, + #[serde(rename = "ciphersuiteVersion")] + _ciphersuite_version: i16, + ksf: OpaqueKsfParams, +} + +#[derive(serde::Deserialize)] +struct OpaqueKsfParams { + #[serde(rename = "memoryKib")] + memory_kib: u32, + iterations: u32, + parallelism: u32, +} + +#[derive(serde::Deserialize)] +struct OpaqueKe1Resp { + #[serde(rename = "exchangeId")] + exchange_id: String, + #[serde(rename = "loginResponse")] + login_response: String, +} + +const EXIT_FAIL: u8 = 1; + +fn env_or_fail(key: &str) -> String { + std::env::var(key).unwrap_or_else(|_| { + eprintln!("dpop-hurl-helper: required env var {key} unset"); + std::process::exit(EXIT_FAIL as i32); + }) +} + +fn fail(msg: impl std::fmt::Display) -> ExitCode { + // Bordered banner so a failure stands out against the interleaved + // server audit log — the last line before this is usually the + // server-side reject that caused it, which visually blends in. + eprintln!(); + eprintln!("┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓"); + eprintln!("┃ dpop-hurl-helper: FAIL ┃"); + eprintln!("┃ {msg}"); + eprintln!("┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛"); + ExitCode::from(EXIT_FAIL) +} + +fn now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +fn random_jti() -> String { + // 16 bytes of OS randomness, base64url'd — plenty of entropy for + // per-request uniqueness; the server's replay cache keys on this. + use p256::elliptic_curve::rand_core::{OsRng, RngCore}; + let mut b = [0u8; 16]; + OsRng.fill_bytes(&mut b); + B64_URL_NO_PAD.encode(b) +} + +/// A persistent-across-scenarios keypair — simulates one browser +/// tab whose IndexedDB entry survives every scenario in this run. +struct KeyBundle { + signing_key: SigningKey, + jwk_x_b64: String, + jwk_y_b64: String, +} + +impl KeyBundle { + fn fresh() -> Self { + // Deterministic-ish seed — this is a test tool, not a + // security surface. Skipping `rand`-dep to keep the binary + // dep footprint identical to what Gate 5 already added. + let mut bytes = [0u8; 32]; + for (i, b) in bytes.iter_mut().enumerate() { + *b = ((i as u8).wrapping_mul(37)).wrapping_add(1); + } + let signing_key = SigningKey::from_bytes(&bytes.into()).expect("valid P-256 scalar"); + let vkey = signing_key.verifying_key(); + let enc = vkey.to_encoded_point(false); + Self { + signing_key, + jwk_x_b64: B64_URL_NO_PAD.encode(enc.x().unwrap()), + jwk_y_b64: B64_URL_NO_PAD.encode(enc.y().unwrap()), + } + } +} + +/// Overrides for building malformed / tampered proofs, per scenario. +#[derive(Default, Clone)] +struct ProofOverrides<'a> { + override_alg: Option<&'a str>, + override_typ: Option<&'a str>, + override_htm: Option<&'a str>, + override_htu: Option<&'a str>, + stale_iat: bool, + /// If `Some`, use this exact string for the `jti` instead of a + /// fresh random one — the replay scenario needs to reuse it. + fixed_jti: Option, + /// If `Some`, include this literal `nonce` claim (even if it's + /// wrong on purpose). If `None`, use whatever the caller + /// tracked from `DPoP-Nonce` response headers. + force_nonce: Option, +} + +/// Build + sign a DPoP proof against the given method + URL. +/// `nonce` is the current server-issued nonce (if any); `overrides` +/// let scenario code tamper with the proof shape. +fn build_proof( + keys: &KeyBundle, + method: &str, + url: &str, + nonce: Option<&str>, + overrides: &ProofOverrides<'_>, +) -> String { + let header = json!({ + "typ": overrides.override_typ.unwrap_or("dpop+jwt"), + "alg": overrides.override_alg.unwrap_or("ES256"), + "jwk": { + "crv": "P-256", + "kty": "EC", + "x": keys.jwk_x_b64, + "y": keys.jwk_y_b64, + }, + }); + let iat = if overrides.stale_iat { + now_secs() - 10_000 + } else { + now_secs() + }; + let mut claims = json!({ + "htm": overrides.override_htm.unwrap_or(method), + "htu": overrides.override_htu.map(str::to_owned).unwrap_or_else(|| canonical_htu(url)), + "iat": iat, + "jti": overrides.fixed_jti.clone().unwrap_or_else(random_jti), + }); + let nonce_to_use = overrides.force_nonce.as_deref().or(nonce); + if let Some(n) = nonce_to_use { + claims.as_object_mut().unwrap().insert( + "nonce".to_string(), + serde_json::Value::String(n.to_string()), + ); + } + + let h_b64 = B64_URL_NO_PAD.encode(header.to_string()); + let p_b64 = B64_URL_NO_PAD.encode(claims.to_string()); + let signing_input = format!("{h_b64}.{p_b64}"); + let sig: Signature = keys.signing_key.sign(signing_input.as_bytes()); + let s_b64 = B64_URL_NO_PAD.encode(sig.to_bytes()); + format!("{h_b64}.{p_b64}.{s_b64}") +} + +fn canonical_htu(url: &str) -> String { + let u = reqwest::Url::parse(url).expect("valid URL for htu"); + format!("{}://{}{}", u.scheme(), u.authority(), u.path()) +} + +/// Log in via OPAQUE, return the access + refresh tokens. +/// +/// `opaque-hurl-helper` runs earlier in `tests/api/run.sh` and +/// mints the OPAQUE envelope for `admin`; from that point on the +/// account is migrated and legacy `POST /api/auth/login` refuses +/// with Phase-4 `opaque_migrated_use_opaque` (403). So this +/// helper drives the OPAQUE handshake directly — same ciphersuite +/// (`OxiCloudSuite`) and same `/params` KSF-fetch pattern as +/// `opaque-hurl-helper`. Long-term this is also what OPAQUE-only +/// mode (`docs/plan/opaque-only.md`) requires: legacy login is +/// on the way out entirely. +async fn opaque_login( + http: &reqwest::Client, + base: &str, + username: &str, + password: &str, +) -> Result<(String, String), String> { + // Fetch server params so client-side Argon2 matches. Per Phase B + // the envelope's OWN KSF is authoritative for that user (via + // `/login/lookup`), but for a test admin whose envelope was + // freshly minted by the OPAQUE helper the two match, so + // `/params` is sufficient here. + let params: OpaqueParamsResp = http + .get(format!("{base}/api/auth/opaque/params")) + .send() + .await + .map_err(|e| format!("opaque /params: {e}"))? + .json() + .await + .map_err(|e| format!("parse /params: {e}"))?; + if !params.enabled { + return Err("server reports OPAQUE disabled — did opaque-hurl-helper run first?".into()); + } + let ksf_params = argon2::Params::new( + params.ksf.memory_kib, + params.ksf.iterations, + params.ksf.parallelism, + None, + ) + .map_err(|e| format!("build Argon2 params: {e}"))?; + let ksf = argon2::Argon2::new( + argon2::Algorithm::Argon2id, + argon2::Version::V0x13, + ksf_params, + ); + + let mut rng = OpaqueRng; + + // ── KE1 — public endpoint, no bearer ───────────────────────────── + let client_login = ClientLogin::::start(&mut rng, password.as_bytes()) + .map_err(|e| format!("ClientLogin::start: {e}"))?; + let ke1_body = json!({ + "userIdentifier": username, + "startLoginRequest": B64.encode(client_login.message.serialize()), + }); + let ke1_res = http + .post(format!("{base}/api/auth/opaque/login/ke1")) + .json(&ke1_body) + .send() + .await + .map_err(|e| format!("ke1 POST: {e}"))?; + if !ke1_res.status().is_success() { + return Err(format!("ke1 returned {}", ke1_res.status())); + } + let ke1: OpaqueKe1Resp = ke1_res + .json() + .await + .map_err(|e| format!("parse ke1: {e}"))?; + let cred_bytes = + decode_opaque_b64(&ke1.login_response).map_err(|e| format!("decode loginResponse: {e}"))?; + let cred_response = CredentialResponse::::deserialize(&cred_bytes) + .map_err(|e| format!("deserialize CredentialResponse: {e}"))?; + + // ── KE3 — finish + submit ───────────────────────────────────────── + let login_finish = client_login + .state + .finish( + password.as_bytes(), + cred_response, + ClientLoginFinishParameters::new(None, opaque_ke::Identifiers::default(), Some(&ksf)), + ) + .map_err(|e| format!("ClientLogin::finish (bad password?): {e}"))?; + // URL_SAFE_NO_PAD on the wire — matches what the SPA sends and + // what the server's handler prefers (accepts both, but this is + // the canonical form). + let ke3_body = json!({ + "exchangeId": ke1.exchange_id, + "finishLoginRequest": B64_URL_NO_PAD.encode(login_finish.message.serialize()), + }); + let ke3_res = http + .post(format!("{base}/api/auth/opaque/login/ke3")) + .json(&ke3_body) + .send() + .await + .map_err(|e| format!("ke3 POST: {e}"))?; + if !ke3_res.status().is_success() { + return Err(format!("ke3 returned {}", ke3_res.status())); + } + let body: serde_json::Value = ke3_res.json().await.map_err(|e| format!("ke3 body: {e}"))?; + let access = body["access_token"] + .as_str() + .ok_or("ke3 response missing access_token")? + .to_string(); + let refresh = body["refresh_token"] + .as_str() + .ok_or("ke3 response missing refresh_token")? + .to_string(); + Ok((access, refresh)) +} + +/// Send a GET request to `path` with a DPoP proof, following the +/// nonce-challenge retry loop. Returns the final response + the +/// nonce currently cached (which the caller threads into follow-up +/// requests). +async fn get_with_dpop( + http: &reqwest::Client, + base: &str, + path: &str, + access_token: &str, + keys: &KeyBundle, + cached_nonce: Option, + overrides: &ProofOverrides<'_>, +) -> Result<(reqwest::Response, Option), String> { + let url = format!("{base}{path}"); + let proof = build_proof(keys, "GET", &url, cached_nonce.as_deref(), overrides); + let res = http + .get(&url) + .bearer_auth(access_token) + .header("DPoP", proof) + .send() + .await + .map_err(|e| format!("GET {path}: {e}"))?; + // Harvest DPoP-Nonce even on failure — the server stamps it + // regardless so the client can retry with the fresh value. + let updated_nonce = res + .headers() + .get("DPoP-Nonce") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned) + .or(cached_nonce); + // Challenge-retry once — mirrors the SPA fetch interceptor. + if res.status() == 401 + && res + .headers() + .get("WWW-Authenticate") + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.contains("use_dpop_nonce")) + { + // Only ONE retry — a second challenge on the retry is a + // server bug and should surface as-is. + let proof2 = build_proof(keys, "GET", &url, updated_nonce.as_deref(), overrides); + let res2 = http + .get(&url) + .bearer_auth(access_token) + .header("DPoP", proof2) + .send() + .await + .map_err(|e| format!("GET {path} (retry): {e}"))?; + let updated_nonce2 = res2 + .headers() + .get("DPoP-Nonce") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned) + .or(updated_nonce); + return Ok((res2, updated_nonce2)); + } + Ok((res, updated_nonce)) +} + +fn expect_status(scenario: &str, res: &reqwest::Response, want: u16) -> Result<(), String> { + if res.status().as_u16() == want { + Ok(()) + } else { + Err(format!( + "scenario {scenario}: expected HTTP {want}, got {}", + res.status() + )) + } +} + +// Defensive-programming pattern: every scenario refreshes the +// cached nonce so a later scenario picks up any rotation the +// server did in between. Scenarios that DON'T re-consume the +// cached nonce (e.g. #6 which passes a bogus value on purpose) +// look like dead assignments to the linter — silence it here +// rather than sprinkle `let _ = n` calls that obscure intent. +#[allow(unused_assignments)] +#[tokio::main(flavor = "current_thread")] +async fn main() -> ExitCode { + let base = env_or_fail("DPOP_HELPER_BASE_URL"); + let username = env_or_fail("DPOP_HELPER_USERNAME"); + let password = env_or_fail("DPOP_HELPER_PASSWORD"); + let base = base.trim_end_matches('/'); + + let http = match reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + { + Ok(c) => c, + Err(e) => return fail(format!("build reqwest client: {e}")), + }; + + // ── 1. Log in via OPAQUE (session created unbound — Gate 3 + // requires the client to send `dpop_jkt` in the login + // body; this helper doesn't, so we get the fail-open + // unbound path). Bind support gets tested via + // `POST /api/auth/dpop/bind` in a later gate. + // + // OPAQUE (not legacy) because `opaque-hurl-helper` + // migrates `admin` earlier in `run.sh`, after which + // legacy login 403s with Phase-4 refusal — and once + // OPAQUE-only mode ships (`docs/plan/opaque-only.md`) + // there IS no legacy path anyway. + let (access, _refresh) = match opaque_login(&http, base, &username, &password).await { + Ok(t) => t, + Err(e) => return fail(e), + }; + + let keys = KeyBundle::fresh(); + let jkt = { + // Compute expected thumbprint for logging — server ignores + // at this gate but future scenarios will compare. + let canonical = format!( + r#"{{"crv":"P-256","kty":"EC","x":"{}","y":"{}"}}"#, + keys.jwk_x_b64, keys.jwk_y_b64 + ); + B64_URL_NO_PAD.encode(Sha256::digest(canonical.as_bytes())) + }; + eprintln!("dpop-hurl-helper: keypair jkt={jkt}"); + + let mut nonce: Option = None; + + // ── Scenario 1: happy path — bootstrap (no nonce) → challenge + // → retry with nonce → 200. The retry loop is inside + // `get_with_dpop`. + let (res, n) = match get_with_dpop( + &http, + base, + "/api/auth/me", + &access, + &keys, + nonce.clone(), + &ProofOverrides::default(), + ) + .await + { + Ok(t) => t, + Err(e) => return fail(e), + }; + if let Err(e) = expect_status("happy_path", &res, 200) { + return fail(e); + } + if n.is_none() { + return fail("happy_path: server did not stamp DPoP-Nonce on response"); + } + nonce = n; + eprintln!( + "dpop-hurl-helper: scenario 1 (happy path) ✓ cached_nonce={:?}", + nonce + ); + + // ── Scenario 2: wrong htm — sign for POST but send GET → 401 + let (res, n) = match get_with_dpop( + &http, + base, + "/api/auth/me", + &access, + &keys, + nonce.clone(), + &ProofOverrides { + override_htm: Some("POST"), + ..Default::default() + }, + ) + .await + { + Ok(t) => t, + Err(e) => return fail(e), + }; + nonce = n; + if let Err(e) = expect_status("wrong_htm", &res, 401) { + return fail(e); + } + eprintln!("dpop-hurl-helper: scenario 2 (wrong htm) ✓"); + + // ── Scenario 3: wrong htu — sign for /api/foo but send /api/auth/me → 401 + let (res, n) = match get_with_dpop( + &http, + base, + "/api/auth/me", + &access, + &keys, + nonce.clone(), + &ProofOverrides { + override_htu: Some("https://not-this-host.example/api/foo"), + ..Default::default() + }, + ) + .await + { + Ok(t) => t, + Err(e) => return fail(e), + }; + nonce = n; + if let Err(e) = expect_status("wrong_htu", &res, 401) { + return fail(e); + } + eprintln!("dpop-hurl-helper: scenario 3 (wrong htu) ✓"); + + // ── Scenario 4: wrong alg (RS256) — 401 + let (res, n) = match get_with_dpop( + &http, + base, + "/api/auth/me", + &access, + &keys, + nonce.clone(), + &ProofOverrides { + override_alg: Some("RS256"), + ..Default::default() + }, + ) + .await + { + Ok(t) => t, + Err(e) => return fail(e), + }; + nonce = n; + if let Err(e) = expect_status("wrong_alg", &res, 401) { + return fail(e); + } + eprintln!("dpop-hurl-helper: scenario 4 (wrong alg) ✓"); + + // ── Scenario 5: wrong typ — 401 + let (res, n) = match get_with_dpop( + &http, + base, + "/api/auth/me", + &access, + &keys, + nonce.clone(), + &ProofOverrides { + override_typ: Some("jwt"), + ..Default::default() + }, + ) + .await + { + Ok(t) => t, + Err(e) => return fail(e), + }; + nonce = n; + if let Err(e) = expect_status("wrong_typ", &res, 401) { + return fail(e); + } + eprintln!("dpop-hurl-helper: scenario 5 (wrong typ) ✓"); + + // ── Scenario 6: stale nonce — simulate the client's cached + // nonce having expired server-side (server restarted, or + // pool TTL elapsed). The server issues a challenge with a + // fresh nonce, and `get_with_dpop` MUST retry once using + // that fresh value from the response header — not the + // stale one from `cached_nonce`. + // + // Pass the bogus value positionally (via `cached_nonce`) + // rather than through the `force_nonce` override — the + // override would survive the retry and cause a second + // challenge, defeating the recovery path we're testing. + let (res, n) = match get_with_dpop( + &http, + base, + "/api/auth/me", + &access, + &keys, + Some("nonce-that-server-does-not-know".to_string()), + &ProofOverrides::default(), + ) + .await + { + Ok(t) => t, + Err(e) => return fail(e), + }; + if let Err(e) = expect_status("stale_nonce_challenge_retry", &res, 200) { + return fail(e); + } + nonce = n; + eprintln!("dpop-hurl-helper: scenario 6 (stale nonce → challenge → retry) ✓"); + + // ── Scenario 7: replay — build a proof, send it TWICE with + // the same jti; second call must be replay-rejected. + // Uses a bespoke send (no retry-loop) so we control both + // submissions of the exact-same bytes. + let fixed_jti = random_jti(); + let url = format!("{base}/api/auth/me"); + let proof_replay = build_proof( + &keys, + "GET", + &url, + nonce.as_deref(), + &ProofOverrides { + fixed_jti: Some(fixed_jti.clone()), + ..Default::default() + }, + ); + let res_first = match http + .get(&url) + .bearer_auth(&access) + .header("DPoP", proof_replay.clone()) + .send() + .await + { + Ok(r) => r, + Err(e) => return fail(format!("replay send 1: {e}")), + }; + if let Err(e) = expect_status("replay_first_send", &res_first, 200) { + return fail(e); + } + let res_second = match http + .get(&url) + .bearer_auth(&access) + .header("DPoP", proof_replay) + .send() + .await + { + Ok(r) => r, + Err(e) => return fail(format!("replay send 2: {e}")), + }; + if let Err(e) = expect_status("replay_second_send", &res_second, 401) { + return fail(e); + } + // The replay 401 is an invalid_dpop_proof shape (not use_dpop_nonce) + let www_auth = res_second + .headers() + .get("WWW-Authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if www_auth.contains("use_dpop_nonce") { + return fail(format!( + "replay: WWW-Authenticate should be invalid_dpop_proof, was: {www_auth}" + )); + } + eprintln!("dpop-hurl-helper: scenario 7 (replay) ✓"); + + // ── Scenario 8: malformed JWS — send garbage in the DPoP + // header. Server-side verifier rejects at the split step. + let res = match http + .get(&url) + .bearer_auth(&access) + .header("DPoP", "not-a-real-jws") + .send() + .await + { + Ok(r) => r, + Err(e) => return fail(format!("malformed send: {e}")), + }; + if let Err(e) = expect_status("malformed", &res, 401) { + return fail(e); + } + eprintln!("dpop-hurl-helper: scenario 8 (malformed) ✓"); + + // ── Scenario 9: no proof at all on a bound-if-required path. + // In opportunistic mode this passes through; in required + // mode the session is unbound (`dpop_jkt IS NULL`) so it + // STILL passes through. Server-side gate 9 will flip this + // once session-context enforcement lands. + let res_no_proof = match http.get(&url).bearer_auth(&access).send().await { + Ok(r) => r, + Err(e) => return fail(format!("no_proof send: {e}")), + }; + if let Err(e) = expect_status("no_proof_unbound_session", &res_no_proof, 200) { + return fail(e); + } + eprintln!("dpop-hurl-helper: scenario 9 (no proof, unbound session → pass) ✓"); + + eprintln!("dpop-hurl-helper: all scenarios passed"); + ExitCode::SUCCESS +} diff --git a/src/main.rs b/src/main.rs index 29661296..6fd1af72 100644 --- a/src/main.rs +++ b/src/main.rs @@ -780,6 +780,10 @@ async fn run() -> Result<(), Box> { app_state.clone(), require_no_password_change_pending_layer, )) + .layer(axum::middleware::from_fn_with_state( + app_state.clone(), + require_dpop_layer, + )) .layer(axum::middleware::from_fn(csrf_middleware)) .layer(axum::middleware::from_fn_with_state( app_state.clone(), @@ -792,6 +796,10 @@ async fn run() -> Result<(), Box> { app_state.clone(), require_no_password_change_pending_layer, )) + .layer(axum::middleware::from_fn_with_state( + app_state.clone(), + require_dpop_layer, + )) .layer(axum::middleware::from_fn(csrf_middleware)) .layer(axum::middleware::from_fn_with_state( app_state.clone(), @@ -814,6 +822,10 @@ async fn run() -> Result<(), Box> { app_state.clone(), require_no_password_change_pending_layer, )) + .layer(axum::middleware::from_fn_with_state( + app_state.clone(), + require_dpop_layer, + )) .layer(axum::middleware::from_fn(csrf_middleware)) .layer(axum::middleware::from_fn_with_state( app_state.clone(), @@ -851,6 +863,10 @@ async fn run() -> Result<(), Box> { app_state.clone(), require_no_password_change_pending_layer, )) + .layer(axum::middleware::from_fn_with_state( + app_state.clone(), + require_dpop_layer, + )) .layer(axum::middleware::from_fn(csrf_middleware)) .layer(axum::middleware::from_fn_with_state( app_state.clone(), @@ -864,6 +880,10 @@ async fn run() -> Result<(), Box> { app_state.clone(), require_no_password_change_pending_layer, )) + .layer(axum::middleware::from_fn_with_state( + app_state.clone(), + require_dpop_layer, + )) .layer(axum::middleware::from_fn(csrf_middleware)) .layer(axum::middleware::from_fn_with_state( app_state.clone(), @@ -885,6 +905,7 @@ async fn run() -> Result<(), Box> { // change-password, and logout internally so the SPA can // complete the reset flow — see the middleware doc for the // allowlist and its rationale. + use oxicloud::interfaces::middleware::dpop::require_dpop_layer; use oxicloud::interfaces::middleware::user::{ require_internal_user_layer, require_no_password_change_pending_layer, }; @@ -893,6 +914,10 @@ async fn run() -> Result<(), Box> { app_state.clone(), require_no_password_change_pending_layer, )) + .layer(axum::middleware::from_fn_with_state( + app_state.clone(), + require_dpop_layer, + )) .layer(axum::middleware::from_fn_with_state( app_state.clone(), require_internal_user_layer, @@ -906,6 +931,10 @@ async fn run() -> Result<(), Box> { app_state.clone(), require_no_password_change_pending_layer, )) + .layer(axum::middleware::from_fn_with_state( + app_state.clone(), + require_dpop_layer, + )) .layer(axum::middleware::from_fn_with_state( app_state.clone(), require_internal_user_layer, @@ -919,6 +948,10 @@ async fn run() -> Result<(), Box> { app_state.clone(), require_no_password_change_pending_layer, )) + .layer(axum::middleware::from_fn_with_state( + app_state.clone(), + require_dpop_layer, + )) .layer(axum::middleware::from_fn_with_state( app_state.clone(), require_internal_user_layer, diff --git a/tests/api/run.sh b/tests/api/run.sh index b7213127..9c140eb1 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -248,4 +248,28 @@ OPAQUE_HELPER_USERNAME="$username" \ OPAQUE_HELPER_PASSWORD="$password" \ "$OPAQUE_HELPER_BIN" || die "OPAQUE crypto handshake failed" +# ── 6. DPoP wire protocol — the parts Hurl can't drive ────────────────── +# Each proof carries a fresh jti, current iat, htm/htu matching the +# exact request, an ES256 signature, and a threaded nonce — none of +# which a declarative .hurl template can compute. See +# `src/bin/dpop-hurl-helper.rs` for the scenario matrix (happy path, +# wrong htm/htu/alg/typ, stale nonce, replay, malformed, fail-open +# when the session is unbound). Runs against the SAME server target +# the OPAQUE helper used — but the server config must set +# `OXICLOUD_DPOP_MODE=opportunistic` (or `required`) or the middleware +# is a pass-through and every failure scenario silently 200s. +DPOP_HELPER_BIN="$REPO_ROOT/target/$BUILD_TARGET/dpop-hurl-helper" +if [[ ! -x "$DPOP_HELPER_BIN" ]]; then + log "Building dpop-hurl-helper ($BUILD_TARGET)..." + case "$BUILD_TARGET" in + debug) (cd "$REPO_ROOT" && cargo build --bin dpop-hurl-helper 2>&1 | tail -n 20) || die "dpop-hurl-helper build failed" ;; + release) (cd "$REPO_ROOT" && cargo build --release --bin dpop-hurl-helper 2>&1 | tail -n 20) || die "dpop-hurl-helper build failed" ;; + esac +fi +log "Running DPoP wire-protocol helper..." +DPOP_HELPER_BASE_URL="$base_url" \ +DPOP_HELPER_USERNAME="$username" \ +DPOP_HELPER_PASSWORD="$password" \ + "$DPOP_HELPER_BIN" || die "DPoP wire-protocol test failed" + log "All tests passed." diff --git a/tests/common/server.env b/tests/common/server.env index 3fa08af1..2a0c4118 100644 --- a/tests/common/server.env +++ b/tests/common/server.env @@ -145,3 +145,14 @@ OXICLOUD_AUTH_OPAQUE_KSF_MEMORY_KIB=8 OXICLOUD_AUTH_OPAQUE_KSF_ITERATIONS=1 OXICLOUD_AUTH_OPAQUE_KSF_PARALLELISM=1 +# DPoP (RFC 9449) — required mode surfaces every verifier / +# nonce / replay bug as a hard 401 (opportunistic would swallow +# some cases as warnings). Safe today because unbound sessions — +# `session.dpop_jkt IS NULL`, which is where the test admin's +# legacy-login lands — are still exempted at the middleware; the +# session-level enforcement flip is Gate 9. Once that arrives, +# every bind-time downgrade path also gets caught here without +# a config change. `dpop-hurl-helper` needs this on or the +# middleware is a pass-through and its scenarios all silently 200. +OXICLOUD_DPOP_MODE=required + From 4c2b244166492fd8793c9f79c8c23126bfd18156 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sat, 8 Aug 2026 17:07:32 +0200 Subject: [PATCH 08/51] feat(DPoP): add logout --- frontend/src/lib/api/endpoints/auth.ts | 18 ++++++++++++++++++ .../services/auth_application_service.rs | 13 ++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/api/endpoints/auth.ts b/frontend/src/lib/api/endpoints/auth.ts index a0155f4a..0ce5b276 100644 --- a/frontend/src/lib/api/endpoints/auth.ts +++ b/frontend/src/lib/api/endpoints/auth.ts @@ -456,6 +456,7 @@ export async function startOidcLink(): Promise { headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, body: '{}' }); +<<<<<<< HEAD if (!res.ok) { const { errorType, message } = await parseErrorBody(res); throw new ApiError(res.status, res.statusText, '/api/auth/oidc/link/start', errorType, message); @@ -503,6 +504,23 @@ export async function logout(): Promise { headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, body: '{}' }); + + // Wipe DPoP browser state so the next login mints a fresh + // keypair — no correlation across the logout boundary is + // desirable (a new session is a new identity from the + // per-request-signature standpoint). Runs UNCONDITIONALLY of + // the logout HTTP status: even if the server call failed, + // the user's intent was to log out, and leaving a stale + // keypair around would confuse the next login's bind step. + try { + const { clearKeypair } = await import('$lib/auth/dpop'); + const { clearNonce } = await import('$lib/auth/dpop-proof'); + await clearKeypair(); + clearNonce(); + } catch (err) { + console.debug('dpop: cleanup failed during logout', err); + } + if (!res.ok) return {}; try { const body = (await res.json()) as { post_logout_url?: unknown }; diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 5ba0d7c7..a8407204 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -1483,7 +1483,15 @@ impl AuthApplicationService { // new one happen in ONE transaction (`rotate_session`) — this path // used to pay two BEGIN/COMMIT pairs per refresh, and DAV clients // rotate constantly (benches/ROUND12.md §4). - let new_session = Session::new( + // + // The DPoP binding travels with the family: if the parent session + // was bound to a browser-held keypair, the refreshed session MUST + // be bound to the same one (see `docs/plan/dpop.md` Gate 7). Same + // browser → same key → same jkt. Skipping this would let a + // refresh silently downgrade the session to unbound, and every + // subsequent request would fail DPoP verification once required + // mode enforces per-session binding. + let mut new_session = Session::new( user.id(), new_refresh_token.clone(), None, @@ -1491,6 +1499,9 @@ impl AuthApplicationService { self.token_service.refresh_token_expiry_days(), session.family_id(), ); + if let Some(jkt) = session.dpop_jkt() { + new_session = new_session.with_dpop_jkt(jkt.to_string()); + } self.session_storage .rotate_session(session.id(), new_session) From ed99b08e62a731c4f82c0b9fb60d01bcc8ec0ec4 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sat, 8 Aug 2026 17:24:44 +0200 Subject: [PATCH 09/51] feat(DPoP): UI: bcast events to support multi tab add also playwright test with the multi tab --- frontend/src/lib/api/endpoints/auth.ts | 6 +- frontend/src/lib/auth/session-broadcast.ts | 78 +++++++++++ frontend/src/routes/+layout.svelte | 19 +++ frontend/vitest-setup.ts | 146 +++++++++++++++++++++ tests/e2e/playwright.config.ts | 15 ++- tests/e2e/scenarios/helpers.ts | 63 +++++++-- tests/e2e/spa/dpop-multi-tab.spec.ts | 99 ++++++++++++++ tests/e2e/spa/favorites.spec.ts | 11 ++ 8 files changed, 417 insertions(+), 20 deletions(-) create mode 100644 frontend/src/lib/auth/session-broadcast.ts create mode 100644 tests/e2e/spa/dpop-multi-tab.spec.ts diff --git a/frontend/src/lib/api/endpoints/auth.ts b/frontend/src/lib/api/endpoints/auth.ts index 0ce5b276..12be86f5 100644 --- a/frontend/src/lib/api/endpoints/auth.ts +++ b/frontend/src/lib/api/endpoints/auth.ts @@ -456,7 +456,6 @@ export async function startOidcLink(): Promise { headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, body: '{}' }); -<<<<<<< HEAD if (!res.ok) { const { errorType, message } = await parseErrorBody(res); throw new ApiError(res.status, res.statusText, '/api/auth/oidc/link/start', errorType, message); @@ -515,8 +514,13 @@ export async function logout(): Promise { try { const { clearKeypair } = await import('$lib/auth/dpop'); const { clearNonce } = await import('$lib/auth/dpop-proof'); + const { broadcastSessionCleared } = await import('$lib/auth/session-broadcast'); await clearKeypair(); clearNonce(); + // Notify every OTHER tab of this origin that the session is + // gone — Gate 8 cross-tab UX. Tabs that were sitting idle + // don't have to wait for their next 401 to notice. + broadcastSessionCleared(); } catch (err) { console.debug('dpop: cleanup failed during logout', err); } diff --git a/frontend/src/lib/auth/session-broadcast.ts b/frontend/src/lib/auth/session-broadcast.ts new file mode 100644 index 00000000..37fb73a6 --- /dev/null +++ b/frontend/src/lib/auth/session-broadcast.ts @@ -0,0 +1,78 @@ +/** + * Cross-tab session invalidation via `BroadcastChannel` — the + * "logout on tab A, tab B knows immediately" wire. + * + * Why this exists: after a logout on Tab A, Tab B still holds an + * in-memory `CryptoKey` handle to the (now-cleared) DPoP keypair + * and a session cookie whose server-side row was just revoked. + * Without a cross-tab signal, Tab B doesn't notice until its next + * network request — at which point the server 401s and the SPA + * bounces to `/login`. That's correctness-safe (see + * `docs/plan/dpop.md` Gate 8), but UX-poor: an idle Tab B silently + * pretends to be logged in for as long as it stays idle. + * + * This module fires a `BroadcastChannel` message so every other + * tab of the same origin can react synchronously — reset its + * session store, redirect to `/login`, no visible drift. + * + * Scope: session **invalidation** only. Not for cross-tab login + * (a fresh sign-in on Tab B while Tab A sits on `/login`); that's + * a general auth-store consistency concern, not DPoP-specific, + * and can be layered on later using the same primitive if needed. + * + * Fail-open contract mirrors the rest of the DPoP stack: if + * `BroadcastChannel` is unavailable (very old Safari, restricted + * webviews), broadcast/subscribe are no-ops. Users lose the + * instant-redirect UX; the natural 401-on-next-request path + * kicks in as before. + */ + +const CHANNEL_NAME = 'oxicloud-session-cleared'; + +/** + * Post a "session cleared" event to every other tab of this + * origin. The current tab does NOT receive its own message — + * `BroadcastChannel` skips the sender by design. + * + * Called from `logout()` after the server round trip completes + * (success or failure — user intent is what matters). Non-fatal + * on failure so the logout flow always finishes. + */ +export function broadcastSessionCleared(): void { + try { + const ch = new BroadcastChannel(CHANNEL_NAME); + ch.postMessage({ kind: 'session_cleared', at: Date.now() }); + ch.close(); + } catch (err) { + console.debug('session-broadcast: postMessage failed', err); + } +} + +/** + * Subscribe to cross-tab session-cleared events. Wire this once + * from the root layout's `onMount`; the callback should reset + * the SPA's session store and navigate to `/login`. + * + * Returns a cleanup function that closes the channel — call it + * from the layout's `onDestroy` so hot-reload during dev doesn't + * leak listeners. + * + * Errors during subscription are swallowed to a no-op: same + * degradation posture as the rest of the DPoP stack. + */ +export function onSessionCleared(callback: () => void): () => void { + try { + const ch = new BroadcastChannel(CHANNEL_NAME); + ch.onmessage = () => { + try { + callback(); + } catch (err) { + console.debug('session-broadcast: callback threw', err); + } + }; + return () => ch.close(); + } catch (err) { + console.debug('session-broadcast: subscribe failed', err); + return () => {}; + } +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 0c847322..198dd264 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -9,6 +9,7 @@ import DialogHost from '$lib/components/DialogHost.svelte'; import Toaster from '$lib/components/Toaster.svelte'; import { setPasswordChangeRequiredHandler } from '$lib/api/client'; + import { onSessionCleared } from '$lib/auth/session-broadcast'; import { session } from '$lib/stores/session.svelte'; import { ui } from '$lib/stores/ui.svelte'; import { hashUrlToPath } from '$lib/utils/hashRedirect'; @@ -61,6 +62,24 @@ }); onMount(async () => { + // Cross-tab logout — when ANOTHER tab logs out, wipe our + // session store and bounce to /login synchronously. Without + // this the natural 401-on-next-request path still catches + // it, just with visible delay for an idle tab. See + // `docs/plan/dpop.md` Gate 8. + // + // The cleanup closure returned by `onSessionCleared` is + // intentionally not wired to `onDestroy` — the root layout + // only unmounts on hot-reload, and a leaked BroadcastChannel + // there is far cheaper than the risk of missing an + // invalidation event during teardown. + onSessionCleared(() => { + session.reset(); + // `replaceState: true` so the back button doesn't return + // the user to the now-dead protected page they were on. + void goto(resolve('/login'), { replaceState: true }); + }); + await killLegacyServiceWorker(); // The instant HTML boot splash has done its job — the app is mounted, so diff --git a/frontend/vitest-setup.ts b/frontend/vitest-setup.ts index 6e6c29d9..6f4ac0df 100644 --- a/frontend/vitest-setup.ts +++ b/frontend/vitest-setup.ts @@ -54,3 +54,149 @@ for (const name of ['localStorage', 'sessionStorage']) { g[name] = new MemoryStorage() as unknown as Storage; } } + +// jsdom has no IndexedDB. `$lib/auth/dpop` uses it as a single-entry +// key/value store for the browser DPoP keypair; without a working +// backing store every test that touches login / fetch prints a +// fail-open `console.debug` on stdout. Rather than pull in +// `fake-indexeddb` for one call-site, provide a minimal in-memory +// shim that covers exactly the API surface `dpop.ts` uses: +// +// indexedDB.open(name) → IDBOpenDBRequest +// .onupgradeneeded / .onsuccess / .onerror → callback slots +// .result → { objectStoreNames.contains, +// createObjectStore, transaction, close } +// store.get(key) / .put(value, key) / .delete(key) +// tx.oncomplete / .onerror +// +// Tests that WANT to exercise DPoP semantics still mock the module +// (see `src/lib/auth/dpop-proof.test.ts`). This shim is for the +// login-path traversals that were noisy without it. +if (!g.indexedDB) { + type Store = Map; + type Db = { + stores: Map; + objectStoreNames: { contains: (n: string) => boolean }; + createObjectStore: (n: string) => void; + transaction: (n: string, mode: 'readonly' | 'readwrite') => FakeTx; + close: () => void; + }; + type FakeReq = { + result: T | undefined; + error: unknown; + onsuccess: ((this: unknown, ev: Event) => void) | null; + onerror: ((this: unknown, ev: Event) => void) | null; + }; + type FakeTx = { + objectStore: (n: string) => FakeStore; + oncomplete: ((this: unknown, ev: Event) => void) | null; + onerror: ((this: unknown, ev: Event) => void) | null; + _done: () => void; + }; + type FakeStore = { + get: (key: string) => FakeReq; + put: (value: unknown, key: string) => FakeReq; + delete: (key: string) => FakeReq; + }; + + // Per-database persistence: opening the same name again gives you + // back your previously-created stores + entries, so the module's + // "read a value someone else wrote" flow works across + // open→close→open cycles within one test. + const databases = new Map>(); + + function makeStore(map: Store, tx: FakeTx): FakeStore { + const microDone = () => queueMicrotask(() => tx._done()); + return { + get(key: string): FakeReq { + const req: FakeReq = { + result: map.get(key), + error: undefined, + onsuccess: null, + onerror: null + }; + queueMicrotask(() => req.onsuccess?.call(req, new Event('success'))); + microDone(); + return req; + }, + put(value: unknown, key: string): FakeReq { + map.set(key, value); + const req: FakeReq = { + result: undefined, + error: undefined, + onsuccess: null, + onerror: null + }; + queueMicrotask(() => req.onsuccess?.call(req, new Event('success'))); + microDone(); + return req; + }, + delete(key: string): FakeReq { + map.delete(key); + const req: FakeReq = { + result: undefined, + error: undefined, + onsuccess: null, + onerror: null + }; + queueMicrotask(() => req.onsuccess?.call(req, new Event('success'))); + microDone(); + return req; + } + }; + } + + function makeDb(name: string): Db { + let stores = databases.get(name); + if (!stores) { + stores = new Map(); + databases.set(name, stores); + } + return { + stores, + objectStoreNames: { contains: (n: string) => stores!.has(n) }, + createObjectStore(n: string): void { + if (!stores!.has(n)) stores!.set(n, new Map()); + }, + transaction(n: string, _mode: 'readonly' | 'readwrite'): FakeTx { + const store = stores!.get(n); + if (!store) throw new Error(`fake-idb: store '${n}' not found`); + const tx: FakeTx = { + objectStore: () => makeStore(store, tx), + oncomplete: null, + onerror: null, + _done: () => tx.oncomplete?.call(tx, new Event('complete')) + }; + return tx; + }, + close(): void { + /* no-op — databases map keeps state across close */ + } + }; + } + + g.indexedDB = { + open(name: string) { + const req: FakeReq & { + onupgradeneeded: ((this: unknown, ev: Event) => void) | null; + } = { + result: undefined, + error: undefined, + onsuccess: null, + onerror: null, + onupgradeneeded: null + }; + queueMicrotask(() => { + const db = makeDb(name); + req.result = db; + // Fire upgradeneeded on FIRST open per database, so the + // module can call createObjectStore('keypair') exactly + // like the real API expects. + const stores = databases.get(name)!; + if (stores.size === 0) req.onupgradeneeded?.call(req, new Event('upgradeneeded')); + req.onsuccess?.call(req, new Event('success')); + }); + return req; + } + }; +} diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index 727561f6..f11a1547 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -66,13 +66,14 @@ export default defineConfig({ // Verbose startup so a CI webServer-readiness timeout shows where the // server stalls (DB connect, migrations, bind) instead of nothing. RUST_LOG: 'info,oxicloud=debug,sqlx=warn,tower_http=info', - // OPAQUE substrate is off for the E2E suite — Hurl exercises it via - // `tests/common/server.env`; the SPA-facing coverage suite doesn't - // need the boot-time init nor the ~200 KiB WASM client. Blanking - // the inherited commonEnv values takes the DI factory's - // `effective_mode == Off` short-circuit. - OXICLOUD_AUTH_OPAQUE_MODE: 'off', - OXICLOUD_AUTH_OPAQUE_SERVER_SETUP: '', + // OPAQUE + DPoP are inherited from `../common/server.env`: + // OXICLOUD_AUTH_OPAQUE_MODE=migrate (Phase 2 silent-migration + // on first legacy login, Phase 4 refusal thereafter) + // OXICLOUD_DPOP_MODE=required (verify every proof; unbound + // sessions still exempt per Gate 5 design) + // Testing under the production shape catches breakage where the + // SPA's fetch interceptor or the migration hook regresses in + // ways that only surface in a real browser + real crypto. }, }, }); diff --git a/tests/e2e/scenarios/helpers.ts b/tests/e2e/scenarios/helpers.ts index c2924e86..c3703b8e 100644 --- a/tests/e2e/scenarios/helpers.ts +++ b/tests/e2e/scenarios/helpers.ts @@ -98,22 +98,61 @@ export async function seedAdmin(baseURL: string, admin = TEST_ADMIN): Promise { - const res = await page.request.post('/api/auth/login', { - data: { username: admin.username, password: admin.password }, - }); - if (!res.ok()) { - throw new Error(`apiLogin failed: ${res.status()} ${await res.text()}`); + // Idempotence check — many specs' beforeEach + test body both call + // apiLogin; the old bare-POST version was a no-op on a live + // session, and callers depend on that. Under UI-driven login, + // navigating to /login when already authenticated triggers the + // SPA's layout guard to redirect away → the login form never + // renders → the fill() below times out. Probe /api/auth/me FIRST: + // 2xx means we're already signed in as SOMEONE. If that's the + // right admin, no-op; otherwise fall through to a fresh login. + const probe = await page.request.get('/api/auth/me').catch(() => null); + if (probe?.ok()) { + const body = (await probe.json().catch(() => ({}))) as { username?: string }; + if (body.username === admin.username) return; } + + await page.goto('/login'); + await page.locator('[data-testid="login-username-input"]').fill(admin.username); + await page.locator('[data-testid="login-password-input"]').fill(admin.password); + await page.locator('[data-testid="login-submit-btn"]').click(); + // Post-login the SPA's `goto(redirectTarget)` sends the user + // to `/files` (default) or a `?redirect=` target. Match the + // default with a glob — the same shape `uiLogin` uses in + // `spa/coverage-helpers.ts` and that Playwright handles well + // under SvelteKit's client-side navigation. The 15s ceiling + // covers the OPAQUE-post-migration path: WASM load + KE1 + + // KE3 + Argon2id. + await page.waitForURL('**/files**', { timeout: 15_000 }); } /** diff --git a/tests/e2e/spa/dpop-multi-tab.spec.ts b/tests/e2e/spa/dpop-multi-tab.spec.ts new file mode 100644 index 00000000..096f1262 --- /dev/null +++ b/tests/e2e/spa/dpop-multi-tab.spec.ts @@ -0,0 +1,99 @@ +import { test, expect, uiLogin } from './coverage-helpers'; + +/** + * SPA · DPoP multi-tab coverage — Gate 8 follow-up. + * + * IndexedDB, cookies, and `BroadcastChannel` are shared across every + * tab of a single Playwright `BrowserContext`. That's the correct + * shape for testing the multi-tab DPoP invariants: + * + * * shared keypair — a second tab opened after login already sees + * the first tab's persisted keypair via IndexedDB, so both tabs + * sign requests with the same JWK thumbprint (`dpop_jkt`) → + * server accepts both under a single bound session. + * * `BroadcastChannel('oxicloud-session-cleared')` — logout on + * one tab must cause the other tab's root layout to reset the + * session store and redirect to `/login` synchronously, without + * waiting for a network round trip to 401. See + * `frontend/src/lib/auth/session-broadcast.ts`. + * + * Runs under `OXICLOUD_AUTH_OPAQUE_MODE=migrate` + + * `OXICLOUD_DPOP_MODE=required` inherited from + * `tests/common/server.env` — so the actual OPAQUE login handshake + * fires (WASM client → KE1 → KE3) and every subsequent request + * carries a DPoP proof the middleware verifies. + */ +test.describe('SPA · DPoP multi-tab', () => { + test('a second tab shares the first tab\'s DPoP keypair (IndexedDB)', async ({ context }) => { + const tabA = await context.newPage(); + await uiLogin(tabA); + // Sanity: tab A landed on an authenticated view. + await expect(tabA.getByTestId('appshell-logo-link')).toBeVisible(); + + // Second tab in the same context — cookies + IndexedDB shared. + const tabB = await context.newPage(); + // Deep-link straight into an authenticated route. If the session + // cookie is shared (it is — cookies are per-context) AND the + // DPoP keypair is shared (it is — IndexedDB is per-origin per- + // context), tab B loads without redirecting to /login. + await tabB.goto('/files'); + await expect(tabB.getByTestId('appshell-logo-link')).toBeVisible({ timeout: 15_000 }); + + // Both tabs' auth store agrees on the same user id — proves the + // shared cookie + shared keypair combination actually authorised + // an API call under DPoP=required against a bound session. + const [uidA, uidB] = await Promise.all([ + tabA.evaluate(async () => { + const res = await fetch('/api/auth/me', { credentials: 'same-origin' }); + return res.ok ? ((await res.json()) as { id: string }).id : null; + }), + tabB.evaluate(async () => { + const res = await fetch('/api/auth/me', { credentials: 'same-origin' }); + return res.ok ? ((await res.json()) as { id: string }).id : null; + }) + ]); + expect(uidA).not.toBeNull(); + expect(uidB).toBe(uidA); + }); + + test('logging out on one tab redirects the other via BroadcastChannel', async ({ context }) => { + const tabA = await context.newPage(); + await uiLogin(tabA); + + const tabB = await context.newPage(); + await tabB.goto('/files'); + await expect(tabB.getByTestId('appshell-logo-link')).toBeVisible({ timeout: 15_000 }); + + // Log out from tab A. Bypass the user-menu UI (which drifts as + // the shell markup evolves) — call `/api/auth/logout` directly + // then post to the BroadcastChannel by hand. Same shape as + // `endpoints/auth.ts::logout()` — the two side-effects the SPA + // does after a successful server logout are (a) wipe DPoP + // state (moot here since tab A is about to close/redirect) and + // (b) broadcast, which is exactly what we simulate. + await tabA.evaluate(async () => { + const csrf = + document.cookie + .split(';') + .map((c) => c.trim()) + .find((c) => c.startsWith('oxicloud_csrf=')) + ?.slice('oxicloud_csrf='.length) ?? ''; + const res = await fetch('/api/auth/logout', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', 'x-csrf-token': csrf }, + body: '{}' + }); + if (!res.ok) throw new Error(`logout returned ${res.status}`); + new BroadcastChannel('oxicloud-session-cleared').postMessage({ + kind: 'session_cleared', + at: Date.now() + }); + }); + + // Tab B should navigate to /login on its own. No API call + // needed — the BroadcastChannel handler in the root layout + // does session.reset() + goto('/login'). + await tabB.waitForURL('**/login**', { timeout: 5_000 }); + }); +}); diff --git a/tests/e2e/spa/favorites.spec.ts b/tests/e2e/spa/favorites.spec.ts index 165fd0f1..9206b24e 100644 --- a/tests/e2e/spa/favorites.spec.ts +++ b/tests/e2e/spa/favorites.spec.ts @@ -21,8 +21,19 @@ test('favorite, view, and unfavorite a folder', async ({ page }) => { await page.goto('/files'); await expect(page.getByTestId(name)).toBeVisible({ timeout: 15_000 }); await page.getByTestId(name).click({ button: 'right' }); + // The context-menu `favorite` click is fire-and-forget in the SPA + // (closeContext() runs before the POST) — the test's next + // navigation can race the write. Wait for the actual POST to + // land before going to /favorites so the list-fetch there sees + // the new row committed. The batch test doesn't need this because + // it queues 2 POSTs sequentially, which naturally gives the first + // one time to commit. + const favorited = page.waitForResponse( + (r) => r.url().includes('/api/favorites') && r.request().method() === 'POST' && r.ok() + ); await page.getByTestId('files-ctx-favorite-item').click(); await expect(page.getByTestId('files-context-menu')).toHaveCount(0); + await favorited; await page.goto('/favorites'); const row = page.getByTestId(name); From 8d6e03a4bb9cf30298b956876aea4fbce05a982a Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sat, 8 Aug 2026 19:55:18 +0200 Subject: [PATCH 10/51] feat(DPoP): check requests and 401 on failure --- frontend/src/lib/api/endpoints/auth.ts | 18 ++- src/application/dtos/user_dto.rs | 8 ++ src/application/ports/auth_ports.rs | 21 +++- .../services/auth_application_service.rs | 82 ++++++++++---- .../services/device_auth_service.rs | 8 +- src/bin/dpop-hurl-helper.rs | 104 ++++++++++++++---- src/infrastructure/services/jwt_service.rs | 33 +++++- src/interfaces/middleware/auth.rs | 7 ++ src/interfaces/middleware/dpop.rs | 55 +++++++-- .../nextcloud/basic_auth_middleware.rs | 4 + src/interfaces/nextcloud/login_v2_handler.rs | 6 + 11 files changed, 283 insertions(+), 63 deletions(-) diff --git a/frontend/src/lib/api/endpoints/auth.ts b/frontend/src/lib/api/endpoints/auth.ts index 12be86f5..d50408c2 100644 --- a/frontend/src/lib/api/endpoints/auth.ts +++ b/frontend/src/lib/api/endpoints/auth.ts @@ -36,9 +36,25 @@ const JSON_HEADERS = { 'Content-Type': 'application/json' }; * a 401 here just means "not logged in" and must not trigger the global * refresh-and-redirect (which would bounce the app in a refresh loop on the * unauthenticated initial load). Returns null when unauthenticated. + * + * Attaches a DPoP proof manually — under `OXICLOUD_DPOP_MODE=required` a + * BOUND session that presents no proof gets 401'd by the middleware + * (Gate 9), and this probe fires on every SPA bootstrap for authenticated + * users. Without the proof, the session load loop would always land in + * "not logged in" on fresh page loads even though cookies are still valid. + * Failure to build a proof (no keypair, missing WebCrypto) falls back to a + * headerless request — the server still accepts it for unbound sessions. */ export async function fetchMe(): Promise { - const res = await fetch('/api/auth/me', { credentials: 'same-origin' }); + let dpop: string | null = null; + try { + const { buildDpopProof } = await import('$lib/auth/dpop-proof'); + dpop = await buildDpopProof('GET', `${location.origin}/api/auth/me`); + } catch { + /* proof unavailable → send without header; unbound sessions still accept */ + } + const headers: HeadersInit = dpop ? { DPoP: dpop } : {}; + const res = await fetch('/api/auth/me', { credentials: 'same-origin', headers }); if (res.status === 401) return null; if (!res.ok) throw new Error(`/api/auth/me failed: ${res.status}`); return (await res.json()) as User; diff --git a/src/application/dtos/user_dto.rs b/src/application/dtos/user_dto.rs index 4943e532..e2580102 100644 --- a/src/application/dtos/user_dto.rs +++ b/src/application/dtos/user_dto.rs @@ -456,6 +456,14 @@ pub struct CurrentUser { pub email: Arc, #[schema(value_type = String)] pub role: SmolStr, + /// DPoP session-binding thumbprint threaded from the JWT's + /// RFC 9449 §5 `cnf.jkt` claim. `None` for unbound sessions + /// (app passwords, NC clients, pre-DPoP). The DPoP middleware + /// reads it to enforce "bound → proof required" from an + /// already-validated token — no session-row lookup on the + /// hot path (see `docs/plan/dpop.md` Gate 9). + #[serde(skip)] + pub dpop_jkt: Option, } // ============================================================================ diff --git a/src/application/ports/auth_ports.rs b/src/application/ports/auth_ports.rs index 414de2d6..d3c81373 100644 --- a/src/application/ports/auth_ports.rs +++ b/src/application/ports/auth_ports.rs @@ -57,6 +57,12 @@ pub struct TokenClaims { pub email: Arc, /// User role pub role: String, + /// RFC 9449 §5 confirmation-key thumbprint — the JWK thumbprint + /// of the DPoP keypair this session was bound to at login. `None` + /// for unbound sessions (app passwords, NC clients, pre-DPoP). + /// The DPoP middleware reads it from the already-validated token + /// (no DB round trip) to enforce "bound session → proof required". + pub dpop_jkt: Option, } /// Port for JWT token operations. @@ -64,8 +70,19 @@ pub struct TokenClaims { /// This trait abstracts token generation and validation, allowing the domain /// layer to remain independent of specific JWT implementations. pub trait TokenServicePort: Send + Sync + 'static { - /// Generate an access token for a user - fn generate_access_token(&self, user: &User) -> Result; + /// Generate an access token for a user. + /// + /// `dpop_jkt` — if `Some`, the token carries an RFC 9449 §5 + /// `cnf.jkt` claim binding it to the browser-held keypair whose + /// public JWK hashes to this thumbprint. Callers pass + /// `session.dpop_jkt()` from the Session being minted; unbound + /// sessions (app passwords, NC clients, pre-DPoP) pass `None` + /// and get a plain token the middleware exempts from DPoP. + fn generate_access_token( + &self, + user: &User, + dpop_jkt: Option<&str>, + ) -> Result; /// Validate a token and extract its claims. /// diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index a8407204..533c33b5 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -1051,8 +1051,37 @@ impl AuthApplicationService { // (benches/ROUND12.md §2, 4.45x). user.register_login(); - // Generate tokens using the injected token service - let access_token = self.token_service.generate_access_token(&user)?; + // Validate DPoP thumbprint FIRST — the same validated value + // has to flow into both the JWT `cnf.jkt` claim (RFC 9449 §5) + // and the session row's `dpop_jkt` column. Reject-before-mint + // avoids issuing a token whose confirmation-key would be + // rejected by the very next request's DPoP middleware. + let validated_jkt = match dpop_jkt.as_deref() { + Some(jkt) => Some(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)", + ) + })?), + None => None, + }; + + // Generate tokens using the injected token service. The + // access token carries the `cnf.jkt` binding when present, + // so the DPoP middleware can enforce "bound → proof required" + // straight from the already-validated JWT — no session-row + // lookup on the hot path. + let access_token = self + .token_service + .generate_access_token(&user, validated_jkt.as_deref())?; let refresh_token = self.token_service.generate_refresh_token(); @@ -1068,22 +1097,8 @@ 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); + if let Some(jkt) = validated_jkt { + session = session.with_dpop_jkt(jkt); } self.session_storage.create_session(session).await?; @@ -1330,7 +1345,12 @@ impl AuthApplicationService { user.mark_email_verified(); self.user_storage.mark_email_verified(user.id()).await?; - let access_token = self.token_service.generate_access_token(&user)?; + // Magic-link redemption is a GET redirect — no way to + // thread `dpop_jkt` into a GET body. Session is minted + // unbound; the SPA calls `POST /api/auth/dpop/bind` + // post-redirect to bind it (see Gate 3). Token accordingly + // ships without `cnf.jkt`. + let access_token = self.token_service.generate_access_token(&user, None)?; let refresh_token = self.token_service.generate_refresh_token(); let session = Session::new( user.id(), @@ -1416,6 +1436,11 @@ impl AuthApplicationService { username: std::sync::Arc::from(user.username().unwrap_or("")), email: std::sync::Arc::from(user.email()), role: smol_str::SmolStr::new_static(user.role().as_str()), + // `verify_credentials` is only called from paths that + // don't need per-session DPoP context (admin/setup + // flows); the DPoP middleware never reads CurrentUser + // populated by this method. Leaving None is safe. + dpop_jkt: None, }) } @@ -1474,8 +1499,14 @@ impl AuthApplicationService { )); } - // Generate new tokens - let access_token = self.token_service.generate_access_token(&user)?; + // Generate new tokens. Inherit the DPoP binding from the + // parent session so the refreshed access token carries the + // same `cnf.jkt` — otherwise every refresh would silently + // downgrade to unbound and the next request would 401 under + // Gate 9 enforcement (see Gate 7). + let access_token = self + .token_service + .generate_access_token(&user, session.dpop_jkt())?; let new_refresh_token = self.token_service.generate_refresh_token(); // New session inherits the family_id so reuse of any ancestor triggers @@ -4233,8 +4264,13 @@ impl AuthApplicationService { }); } - // 6. Issue internal tokens (same as regular login) - let access_token = self.token_service.generate_access_token(&user)?; + // 6. Issue internal tokens (same as regular login). OIDC + // callback is a GET redirect — no way to thread `dpop_jkt` + // through the browser's redirect chain. Session is minted + // unbound; the SPA calls `POST /api/auth/dpop/bind` post- + // redirect to bind it (see Gate 3). Token accordingly ships + // without `cnf.jkt`. + let access_token = self.token_service.generate_access_token(&user, None)?; let refresh_token = self.token_service.generate_refresh_token(); let mut session = Session::new( diff --git a/src/application/services/device_auth_service.rs b/src/application/services/device_auth_service.rs index 1553ca75..1fbdf5ce 100644 --- a/src/application/services/device_auth_service.rs +++ b/src/application/services/device_auth_service.rs @@ -175,8 +175,12 @@ impl DeviceAuthService { // Fetch user to generate tokens let user = self.user_storage.get_user_by_id(user_id).await?; - // Generate internal JWT access token + refresh token - let access_token = self.token_service.generate_access_token(&user)?; + // Generate internal JWT access token + refresh token. Device- + // authorization sessions (RFC 8628) are for CLI / TV / device + // clients that don't run WebCrypto — always unbound (`None` + // for the `dpop_jkt` param), which the DPoP middleware exempts + // from proof requirements. See `docs/plan/dpop.md` Gate 9. + let access_token = self.token_service.generate_access_token(&user, None)?; let refresh_token = self.token_service.generate_refresh_token(); // Persist refresh token as a session diff --git a/src/bin/dpop-hurl-helper.rs b/src/bin/dpop-hurl-helper.rs index 3a0ed0d8..140805e4 100644 --- a/src/bin/dpop-hurl-helper.rs +++ b/src/bin/dpop-hurl-helper.rs @@ -226,6 +226,7 @@ async fn opaque_login( base: &str, username: &str, password: &str, + dpop_jkt: Option<&str>, ) -> Result<(String, String), String> { // Fetch server params so client-side Argon2 matches. Per Phase B // the envelope's OWN KSF is authoritative for that user (via @@ -295,10 +296,19 @@ async fn opaque_login( // URL_SAFE_NO_PAD on the wire — matches what the SPA sends and // what the server's handler prefers (accepts both, but this is // the canonical form). - let ke3_body = json!({ + // Include `dpopJkt` when set so the server binds the session + // to the browser-held (well, test-held) keypair via Gate 3. + // Downstream scenarios can then exercise bound-session paths. + let mut ke3_body = json!({ "exchangeId": ke1.exchange_id, "finishLoginRequest": B64_URL_NO_PAD.encode(login_finish.message.serialize()), }); + if let Some(jkt) = dpop_jkt { + ke3_body + .as_object_mut() + .unwrap() + .insert("dpopJkt".to_string(), json!(jkt)); + } let ke3_res = http .post(format!("{base}/api/auth/opaque/login/ke3")) .json(&ke3_body) @@ -412,26 +422,19 @@ async fn main() -> ExitCode { Err(e) => return fail(format!("build reqwest client: {e}")), }; - // ── 1. Log in via OPAQUE (session created unbound — Gate 3 - // requires the client to send `dpop_jkt` in the login - // body; this helper doesn't, so we get the fail-open - // unbound path). Bind support gets tested via - // `POST /api/auth/dpop/bind` in a later gate. + // ── 1. Mint the persistent keypair FIRST, compute its JWK + // thumbprint, then log in via OPAQUE passing that + // thumbprint so the resulting session is bound (Gate 3). + // That way subsequent scenarios exercise the bound-path + // enforcement Gate 9 lit up: bound session + missing + // proof → 401, wrong-jkt proof → 401 `jkt_mismatch`. // // OPAQUE (not legacy) because `opaque-hurl-helper` // migrates `admin` earlier in `run.sh`, after which // legacy login 403s with Phase-4 refusal — and once - // OPAQUE-only mode ships (`docs/plan/opaque-only.md`) - // there IS no legacy path anyway. - let (access, _refresh) = match opaque_login(&http, base, &username, &password).await { - Ok(t) => t, - Err(e) => return fail(e), - }; - + // OPAQUE-only mode ships there IS no legacy path anyway. let keys = KeyBundle::fresh(); let jkt = { - // Compute expected thumbprint for logging — server ignores - // at this gate but future scenarios will compare. let canonical = format!( r#"{{"crv":"P-256","kty":"EC","x":"{}","y":"{}"}}"#, keys.jwk_x_b64, keys.jwk_y_b64 @@ -440,6 +443,12 @@ async fn main() -> ExitCode { }; eprintln!("dpop-hurl-helper: keypair jkt={jkt}"); + let (access, _refresh) = match opaque_login(&http, base, &username, &password, Some(&jkt)).await + { + Ok(t) => t, + Err(e) => return fail(e), + }; + let mut nonce: Option = None; // ── Scenario 1: happy path — bootstrap (no nonce) → challenge @@ -670,19 +679,70 @@ async fn main() -> ExitCode { } eprintln!("dpop-hurl-helper: scenario 8 (malformed) ✓"); - // ── Scenario 9: no proof at all on a bound-if-required path. - // In opportunistic mode this passes through; in required - // mode the session is unbound (`dpop_jkt IS NULL`) so it - // STILL passes through. Server-side gate 9 will flip this - // once session-context enforcement lands. + // ── Scenario 9: bound session with NO proof — Gate 9 + // enforcement. Under `required` mode this must 401 with a + // `use_dpop_nonce` challenge (server treats missing proof + // on a bound session the same shape as a nonce-challenge + // to nudge the client back onto the DPoP path). Under + // `opportunistic` mode this would 200 with only a warning + // audit line. Test env pins `required` (see + // `tests/common/server.env`). let res_no_proof = match http.get(&url).bearer_auth(&access).send().await { Ok(r) => r, Err(e) => return fail(format!("no_proof send: {e}")), }; - if let Err(e) = expect_status("no_proof_unbound_session", &res_no_proof, 200) { + if let Err(e) = expect_status("no_proof_bound_session_required", &res_no_proof, 401) { return fail(e); } - eprintln!("dpop-hurl-helper: scenario 9 (no proof, unbound session → pass) ✓"); + eprintln!("dpop-hurl-helper: scenario 9 (no proof, bound session, required → 401) ✓"); + + // ── Scenario 10: bound session, valid proof shape but signed + // by a DIFFERENT keypair than the session was bound to. + // Verifier fires `jkt_mismatch` → 401. The classic attacker + // scenario: cookie stolen, attacker mints their own DPoP + // keypair, valid proof shape but wrong key. + let rogue_keys = KeyBundle { + signing_key: { + let mut bytes = [0u8; 32]; + for (i, b) in bytes.iter_mut().enumerate() { + *b = ((i as u8).wrapping_mul(41)).wrapping_add(7); + } + SigningKey::from_bytes(&bytes.into()).expect("valid P-256 scalar") + }, + jwk_x_b64: String::new(), + jwk_y_b64: String::new(), + }; + // Rebuild x/y for the rogue key. + let rogue_enc = rogue_keys + .signing_key + .verifying_key() + .to_encoded_point(false); + let rogue_keys = KeyBundle { + signing_key: rogue_keys.signing_key, + jwk_x_b64: B64_URL_NO_PAD.encode(rogue_enc.x().unwrap()), + jwk_y_b64: B64_URL_NO_PAD.encode(rogue_enc.y().unwrap()), + }; + let rogue_proof = build_proof( + &rogue_keys, + "GET", + &url, + nonce.as_deref(), + &ProofOverrides::default(), + ); + let res_rogue = match http + .get(&url) + .bearer_auth(&access) + .header("DPoP", rogue_proof) + .send() + .await + { + Ok(r) => r, + Err(e) => return fail(format!("rogue-key send: {e}")), + }; + if let Err(e) = expect_status("wrong_jkt_bound_session", &res_rogue, 401) { + return fail(e); + } + eprintln!("dpop-hurl-helper: scenario 10 (bound session + wrong-jkt proof → 401) ✓"); eprintln!("dpop-hurl-helper: all scenarios passed"); ExitCode::SUCCESS diff --git a/src/infrastructure/services/jwt_service.rs b/src/infrastructure/services/jwt_service.rs index be04d3b1..9f8385a2 100644 --- a/src/infrastructure/services/jwt_service.rs +++ b/src/infrastructure/services/jwt_service.rs @@ -45,6 +45,23 @@ struct JwtClaims { pub email: Arc, /// User role for authorization checks pub role: String, + /// RFC 9449 §5 confirmation-key claim: JWK thumbprint of the + /// browser-held DPoP keypair the session was bound to at login. + /// `None` for unbound sessions (app passwords, Nextcloud clients, + /// pre-DPoP sessions). Populated at token mint time from + /// `session.dpop_jkt`; the DPoP middleware reads it to enforce + /// "bound session → proof required" without a DB round trip. + /// + /// Serialised as `{"cnf": {"jkt": "..."}}` to match RFC 9449. + #[serde(skip_serializing_if = "Option::is_none")] + pub cnf: Option, +} + +/// RFC 9449 §5 confirmation-key wrapper. Only the `jkt` member is +/// used today; future extensions (`x5t#S256`, etc.) would live here. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct CnfClaim { + pub jkt: String, } impl From for TokenClaims { @@ -64,6 +81,7 @@ impl From for TokenClaims { username: claims.username, email: claims.email, role: claims.role, + dpop_jkt: claims.cnf.map(|c| c.jkt), } } } @@ -175,7 +193,11 @@ impl JwtTokenService { } impl TokenServicePort for JwtTokenService { - fn generate_access_token(&self, user: &User) -> Result { + fn generate_access_token( + &self, + user: &User, + dpop_jkt: Option<&str>, + ) -> Result { let now = Utc::now().timestamp(); // Log information for debugging @@ -194,6 +216,9 @@ impl TokenServicePort for JwtTokenService { username: Arc::from(user.username().unwrap_or("")), email: Arc::from(user.email()), role: user.role().as_str().to_string(), + cnf: dpop_jkt.map(|jkt| CnfClaim { + jkt: jkt.to_string(), + }), }; // Log JWT claims for debugging @@ -306,7 +331,7 @@ mod tests { let user = create_test_user(); let token = service - .generate_access_token(&user) + .generate_access_token(&user, None) .expect("Should generate token"); let claims = service @@ -345,7 +370,7 @@ mod tests { let user = create_test_user(); let token = service - .generate_access_token(&user) + .generate_access_token(&user, None) .expect("Should generate token"); // First call: cache miss — performs full HMAC verification @@ -372,7 +397,7 @@ mod tests { 86400, ); let token = service - .generate_access_token(&create_test_user()) + .generate_access_token(&create_test_user(), None) .expect("Should generate token"); // Miss populates the cache; hit must hand back the very same diff --git a/src/interfaces/middleware/auth.rs b/src/interfaces/middleware/auth.rs index 0c128f94..7f2a70ec 100644 --- a/src/interfaces/middleware/auth.rs +++ b/src/interfaces/middleware/auth.rs @@ -224,6 +224,7 @@ pub async fn auth_middleware( username: Arc::clone(&claims.username), email: Arc::clone(&claims.email), role, + dpop_jkt: claims.dpop_jkt.clone(), }); request.extensions_mut().insert(current_user); tracing::Span::current() @@ -267,11 +268,16 @@ pub async fn auth_middleware( "App password authentication successful for user: {}", uname ); + // App-password sessions are always unbound — + // they belong to NC clients / CLI / mobile + // tools without WebCrypto. DPoP middleware + // exempts them. let current_user = Arc::new(CurrentUser { id: user_id, username: uname, email, role, + dpop_jkt: None, }); request.extensions_mut().insert(current_user); tracing::Span::current() @@ -341,6 +347,7 @@ pub async fn auth_middleware( username: Arc::clone(&claims.username), email: Arc::clone(&claims.email), role, + dpop_jkt: claims.dpop_jkt.clone(), }); request.extensions_mut().insert(current_user); request.extensions_mut().insert(CookieAuthenticated); diff --git a/src/interfaces/middleware/dpop.rs b/src/interfaces/middleware/dpop.rs index f975070a..ac34eec5 100644 --- a/src/interfaces/middleware/dpop.rs +++ b/src/interfaces/middleware/dpop.rs @@ -96,7 +96,7 @@ pub async fn require_dpop_layer( // No authenticated user → pass through (upstream auth layer // already handled or will handle the 401). We only concern // ourselves with proof-carrying requests on authenticated paths. - let Some(_current_user) = request.extensions().get::>() else { + let Some(current_user) = request.extensions().get::>().cloned() else { return next.run(request).await; }; @@ -106,14 +106,44 @@ pub async fn require_dpop_layer( .and_then(|v| v.to_str().ok()) .map(str::to_owned); - // GATE 5 SCOPE: session-level `dpop_jkt` lookup is deferred (see - // module docstring). For opportunistic mode: - // - proof present → verify with expected_jkt=None (accept any - // jkt shape; still catches malformed/bad-sig/wrong-htu bugs) - // - proof absent → pass through, no warning yet - // For required mode: same for now — Gate 9 flips to real - // per-session enforcement once session context is wired. + // Gate 9 enforcement — the session's binding (from the JWT's + // `cnf.jkt` claim, populated at token mint time from + // `session.dpop_jkt`) tells us whether a proof is REQUIRED: + // + // * unbound session (`dpop_jkt IS NONE`) — proof optional. + // Covers app passwords, NC clients, pre-DPoP sessions. + // * bound session — proof MANDATORY in required mode; a + // warning-only signal in opportunistic mode so operators + // can spot stale SPA versions before flipping enforcement. + let expected_jkt = current_user.dpop_jkt.as_deref(); let Some(proof) = dpop_header else { + match (mode, expected_jkt) { + (DpopMode::Required, Some(_)) => { + tracing::info!( + target: "audit", + event = "dpop.verify_failed", + reason = "proof_missing_on_bound_session", + caller_id = %current_user.id, + path = %request.uri().path(), + "👮🏻‍♂️ DPoP required: bound session request has no proof", + ); + return nonce_challenge_response(&nonce_service); + } + (DpopMode::Opportunistic, Some(_)) => { + // Warning-only — telemetry for the rollout window. + // Emit the signal so operators can decide when to + // flip default to `required`; the request still + // completes so old clients don't break. + tracing::info!( + target: "audit", + event = "dpop.header_missing_but_session_bound", + caller_id = %current_user.id, + path = %request.uri().path(), + "⚠️ DPoP: bound session sent request without a proof", + ); + } + _ => { /* unbound session or off mode — nothing to do */ } + } let response = next.run(request).await; return stamp_current_nonce(response, &nonce_service); }; @@ -136,7 +166,14 @@ pub async fn require_dpop_layer( htm: &method, htu: &htu, now_secs, - expected_jkt: None, // Session-level pin comes in a later gate + // Gate 9: pin to the session's binding when present. The + // verifier returns `JktMismatch` if the proof's public + // key thumbprint doesn't match — an attacker who stole a + // bound cookie AND generated their own DPoP keypair fails + // here. `None` means the session was minted unbound so + // any well-formed proof passes the jkt check (still gets + // htm/htu/nonce/replay verification). + expected_jkt, }; match verify_proof(&proof, &ctx) { Ok(verified) => { diff --git a/src/interfaces/nextcloud/basic_auth_middleware.rs b/src/interfaces/nextcloud/basic_auth_middleware.rs index 7e088f17..dc37620a 100644 --- a/src/interfaces/nextcloud/basic_auth_middleware.rs +++ b/src/interfaces/nextcloud/basic_auth_middleware.rs @@ -203,11 +203,15 @@ pub async fn basic_auth_middleware( // `Arc` extension AND `NcSession.user` (the old // code built the struct, cloned it for the extension, then // moved the original — 2-3 String allocs per request). + // Nextcloud clients are always unbound — they authenticate + // with app passwords via Basic Auth, no WebCrypto, no DPoP. + // Middleware exempts unbound sessions per Gate 9 design. let current_user = Arc::new(CurrentUser { id: user_id, username: uname, email, role, + dpop_jkt: None, }); // ── Resolve chroot from the Basic Auth drive marker ───── diff --git a/src/interfaces/nextcloud/login_v2_handler.rs b/src/interfaces/nextcloud/login_v2_handler.rs index c8951b47..26a7d8d0 100644 --- a/src/interfaces/nextcloud/login_v2_handler.rs +++ b/src/interfaces/nextcloud/login_v2_handler.rs @@ -342,6 +342,9 @@ pub async fn handle_oidc_login_completion( username: std::sync::Arc::from(username), email: std::sync::Arc::from(user_dto.email.as_str()), role: smol_str::SmolStr::new(&user_dto.role), + // NC login-flow-v2 mints an app password — no browser, no + // WebCrypto, always unbound. DPoP middleware exempts. + dpop_jkt: None, }; let drives = match state @@ -553,6 +556,9 @@ pub async fn handle_drive_pick( username: std::sync::Arc::from(username.as_str()), email: std::sync::Arc::from(user_dto.email.as_str()), role: smol_str::SmolStr::new(&user_dto.role), + // NC login-flow-v2 mints an app password — no browser, no + // WebCrypto, always unbound. DPoP middleware exempts. + dpop_jkt: None, }; let _folder = match state From 56726618dcebb86876de5402d65d8a52d74a8403 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sat, 8 Aug 2026 23:17:59 +0200 Subject: [PATCH 11/51] feat(dpop): upgrade migration id --- ...sessions_dpop_jkt.sql => 20261012000000_sessions_dpop_jkt.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename migrations/{20261008000000_sessions_dpop_jkt.sql => 20261012000000_sessions_dpop_jkt.sql} (100%) diff --git a/migrations/20261008000000_sessions_dpop_jkt.sql b/migrations/20261012000000_sessions_dpop_jkt.sql similarity index 100% rename from migrations/20261008000000_sessions_dpop_jkt.sql rename to migrations/20261012000000_sessions_dpop_jkt.sql From a8e801d1f9e4e9ce81360852f889bf5fbc277c92 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 9 Aug 2026 00:07:05 +0200 Subject: [PATCH 12/51] feat(dpop): add documentation --- docs/config/env.md | 8 ++++++++ example.env | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/docs/config/env.md b/docs/config/env.md index 5a300ded..d8bb5d14 100644 --- a/docs/config/env.md +++ b/docs/config/env.md @@ -62,6 +62,14 @@ OPAQUE (RFC 9807) is a zero-knowledge password-authenticated key exchange: the p | `OXICLOUD_AUTH_OPAQUE_KSF_ITERATIONS` | `1` | Client-side Argon2id iteration count (OWASP interactive-auth recommendation). | | `OXICLOUD_AUTH_OPAQUE_KSF_PARALLELISM` | `1` | Client-side Argon2id parallelism lanes (OWASP recommendation). Higher only helps on multi-core hardware and can hurt single-core / older mobile devices. | +### DPoP — session cookie binding (RFC 9449) + +DPoP cryptographically binds a session cookie to a browser-held ECDSA keypair (P-256, non-extractable via `SubtleCrypto`). Every request carries a signed proof the middleware verifies against the session's binding. Closes the info-stealer replay threat: a cookie copied to another machine is useless without the private key. Non-browser clients (Nextcloud sync via app passwords, CLI via device-authorization) never bind and are exempted at the middleware regardless of mode. See `docs/plan/dpop.md` for the full rollout plan. + +| Variable | Default | Description | +|---|---|---| +| `OXICLOUD_DPOP_MODE` | `off` | Enforcement mode. `off` = middleware pass-through (default, ship-safe). `opportunistic` = verify when a proof is present, log `dpop.header_missing_but_session_bound` audit when absent on a bound session, but allow the request through (rollout mode — catches client bugs). `required` = bound sessions MUST present a valid proof or 401. Unbound sessions always exempt. Recommended rollout: `off` → `opportunistic` for 2-4 weeks → `required`. For DPoP to be meaningful, cookies must be `Secure` (`OXICLOUD_COOKIE_SECURE=true` in production over HTTPS) and reverse-proxy `X-Forwarded-Proto` / `X-Forwarded-Host` must reach the app so the `htu` claim canonicalises correctly. | + ### Rate Limiting & Account Lockout | Variable | Default | Description | diff --git a/example.env b/example.env index 3ac30d60..e7c86ee4 100644 --- a/example.env +++ b/example.env @@ -250,6 +250,46 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/oxicloud # helps on multi-core devices and hurts single-core / older mobile) #OXICLOUD_AUTH_OPAQUE_KSF_PARALLELISM=1 +# ----------------------------------------------------------------------------- +# DPOP — session cookie binding to a browser-held keypair (RFC 9449) +# ----------------------------------------------------------------------------- +# Each SPA session generates a P-256 ECDSA keypair with `extractable: false` +# in the browser at login time; the JWK thumbprint is sent as `dpop_jkt` +# and stored on the session row. Every subsequent request carries a signed +# DPoP proof header the middleware verifies against the session's binding. +# +# Threat closed: an info-stealer that copies the cookie to another machine +# cannot replay the session — the private key never leaves the browser +# (SubtleCrypto stores it in the crypto subsystem; JS can only call +# `sign()` on the handle, never `exportKey()`). +# +# Non-browser clients (Nextcloud sync, mobile apps via Basic Auth on app +# passwords, CLI via device-authorization) never bind a keypair — their +# session rows have `dpop_jkt IS NULL` and the middleware exempts them +# regardless of mode. So enabling this does NOT break your NC clients. +# +# Values: off | opportunistic | required +# off — middleware pass-through, no verification. Default. Ship- +# safe while the client rollout catches up. +# opportunistic — verify when a proof is present, reject invalid ones; +# allow when absent (log a warning if the session was +# bound). Rollout mode — catches client bugs before +# flipping enforcement. +# required — bound sessions MUST present a valid proof or 401. +# Unbound sessions still work (see NC-client note above). +# +# Recommended rollout: off → opportunistic (2-4 weeks, watch audit for +# `dpop.header_missing_but_session_bound` counts trending to zero) → +# required. See `docs/plan/dpop.md` for the full rollout plan. +# +# NB: for DPoP to be meaningful, cookies must be `Secure` (HTTPS) — +# there's no point cryptographically binding a session that ships over +# plain HTTP. Set `OXICLOUD_COOKIE_SECURE=true` in production. Also +# ensure `X-Forwarded-Proto` + `X-Forwarded-Host` reach the app if +# you're behind a reverse proxy — the middleware reads those to build +# the canonical `htu` claim the proof binds to. +#OXICLOUD_DPOP_MODE=off + # ----------------------------------------------------------------------------- # RATE LIMITING & ACCOUNT LOCKOUT # ----------------------------------------------------------------------------- From 8222dbbd4793eeffc22c923ac378651656e3a994 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 9 Aug 2026 00:07:52 +0200 Subject: [PATCH 13/51] chore(dpop): propagate X-Forwarded-* in vite dev mode --- frontend/vite.config.ts | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 685ea832..fe16ba9b 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -19,18 +19,33 @@ const BACKEND = process.env.OXICLOUD_BACKEND ?? 'http://localhost:8086'; // by default so normal dev/release builds carry no instrumentation overhead. const COVERAGE = process.env.COVERAGE === '1'; +// `changeOrigin: true` rewrites the `Host` header to match the backend's +// authority (localhost:8086) so the backend answers as if the request +// arrived natively. But DPoP's `htu` claim is bound to what the browser +// sees (localhost:5173) — a bare rewrite makes the server compute +// `htu = http://localhost:8086/api/…` and fire `dpop.verify_failed +// reason=wrong_htu` on every request. Set `X-Forwarded-*` so the DPoP +// middleware (which mirrors production reverse-proxy behaviour) can +// reconstruct the browser-visible URL. Same reasoning that applies to +// nginx/Cloudflare in front of the deployment applies to Vite in dev. +const DEV_ORIGIN_HEADERS = { + 'X-Forwarded-Proto': 'http', + 'X-Forwarded-Host': 'localhost:5173' +}; +const p = (target: string) => ({ target, changeOrigin: true, headers: DEV_ORIGIN_HEADERS }); + const proxy = { - '/api': { target: BACKEND, changeOrigin: true }, - '/locales': { target: BACKEND, changeOrigin: true }, - '/.well-known': { target: BACKEND, changeOrigin: true }, - '/remote.php': { target: BACKEND, changeOrigin: true }, - '/ocs': { target: BACKEND, changeOrigin: true }, - '/status.php': { target: BACKEND, changeOrigin: true }, - '/webdav': { target: BACKEND, changeOrigin: true }, - '/caldav': { target: BACKEND, changeOrigin: true }, - '/carddav': { target: BACKEND, changeOrigin: true }, - '/wopi': { target: BACKEND, changeOrigin: true }, - '/magic': { target: BACKEND, changeOrigin: true } + '/api': p(BACKEND), + '/locales': p(BACKEND), + '/.well-known': p(BACKEND), + '/remote.php': p(BACKEND), + '/ocs': p(BACKEND), + '/status.php': p(BACKEND), + '/webdav': p(BACKEND), + '/caldav': p(BACKEND), + '/carddav': p(BACKEND), + '/wopi': p(BACKEND), + '/magic': p(BACKEND) }; export default defineConfig({ From 82bd2e4d227a9bf224ba4eb5c52da630c6933fc0 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 9 Aug 2026 00:08:50 +0200 Subject: [PATCH 14/51] fix(dpop): do not sign GET /... tumbnail/images/ basic assets no need sign this simole GET to this simplify an hijacked session could retreive these assets but cannot get the list of them --- src/interfaces/middleware/dpop.rs | 144 +++++++++++++++++++++++++++++- 1 file changed, 143 insertions(+), 1 deletion(-) diff --git a/src/interfaces/middleware/dpop.rs b/src/interfaces/middleware/dpop.rs index ac34eec5..4587e2b1 100644 --- a/src/interfaces/middleware/dpop.rs +++ b/src/interfaces/middleware/dpop.rs @@ -49,6 +49,83 @@ use crate::infrastructure::services::dpop_verifier::{ use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::CurrentUser; +/// Content-serving GETs the browser fetches directly from ``, +/// ``, `` — no JS in the loop, so +/// there's no way to attach a DPoP proof (the fetch interceptor never +/// runs). Exempt them from the "bound session missing proof" hard +/// enforcement so the SPA can still show thumbnails, play videos, and +/// download files after DPoP=required flips on. +/// +/// Security posture: an attacker with a stolen cookie can GET these +/// URLs BUT ONLY if they already know a specific 128-bit UUID. +/// Every listing / discovery endpoint (`GET /api/folders//children`, +/// `/search`, `/api/files/by-hash`, `/api/photos`, …) still requires +/// DPoP because it's called via `apiFetch`. So a bare stolen cookie +/// gives the attacker "download the exact IDs you already know" — +/// effectively nothing without prior knowledge. +/// +/// Proofs that ARE sent on these paths (e.g. an image preloader that +/// went through `apiFetch` and blob'd) still get fully verified — +/// this only bypasses the missing-proof reject, not the verifier +/// itself. +/// +/// Long-term Option B (signed short-lived URL tokens) would let us +/// remove this allowlist entirely; tracked as a separate task. +fn is_content_serve_get(method: &axum::http::Method, path: &str) -> bool { + if method != axum::http::Method::GET { + return false; + } + // Note: `path` is nest-stripped by axum (`/api` prefix removed by + // the `/api` nest), so we match against the inner segment. + // `/files/` (download / inline) + // `/files//thumbnail/` (thumbnails) + // `/folders//download` (zip download) + // `/photos//preview` (photo preview, if present) + matches_content_path(path) +} + +fn matches_content_path(path: &str) -> bool { + // Segment-based matching keeps this cheap and readable — no regex + // dep, no leading-slash surprises. Every content endpoint has an + // opaque UUID at position 2, so we match on the shape rather than + // the specific id. The UUID check is what disambiguates + // `/files/` (content) from `/files/by-hash` (a listing + // endpoint that MUST stay behind DPoP for anti-enumeration). + let mut segs = path.trim_start_matches('/').split('/'); + let (Some(root), Some(id), rest_first) = (segs.next(), segs.next(), segs.next()) else { + return false; + }; + if !looks_like_uuid(id) { + return false; + } + match (root, rest_first) { + // /files/ — download / inline + ("files", None) => true, + // /files//thumbnail/... — thumbnails + ("files", Some("thumbnail")) => true, + // /folders//download — zip + ("folders", Some("download")) => true, + // /photos//preview — photo preview (best-effort match; + // adds no risk if the endpoint doesn't exist server-side) + ("photos", Some("preview")) => true, + _ => false, + } +} + +/// Cheap UUID-shape check: 36 chars, hyphens at positions 8, 13, 18, +/// 23, everything else hex. Not a full parse (nothing here needs the +/// bytes) — just enough to distinguish `` from named endpoints +/// like `by-hash`, `upload`, `search`. +fn looks_like_uuid(s: &str) -> bool { + if s.len() != 36 { + return false; + } + s.bytes().enumerate().all(|(i, b)| match i { + 8 | 13 | 18 | 23 => b == b'-', + _ => b.is_ascii_hexdigit(), + }) +} + /// Resolve the request's external `(scheme, host)` — what the client /// sees the URL as, which is what its DPoP proof's `htu` was built /// from. Behind a reverse proxy, the internal request scheme + @@ -118,7 +195,9 @@ pub async fn require_dpop_layer( let expected_jkt = current_user.dpop_jkt.as_deref(); let Some(proof) = dpop_header else { match (mode, expected_jkt) { - (DpopMode::Required, Some(_)) => { + (DpopMode::Required, Some(_)) + if !is_content_serve_get(request.method(), request.uri().path()) => + { tracing::info!( target: "audit", event = "dpop.verify_failed", @@ -362,4 +441,67 @@ mod tests { ("http".to_owned(), "localhost".to_owned()) ); } + + // Content-serve allowlist — the paths browsers fetch directly + // from anchors / img / video without JS in the loop. Under + // `required` mode + bound session, missing-proof must NOT reject + // these or the SPA breaks (downloads, thumbnails, video streaming + // all silently 401). + + fn get(path: &str) -> bool { + is_content_serve_get(&axum::http::Method::GET, path) + } + fn post(path: &str) -> bool { + is_content_serve_get(&axum::http::Method::POST, path) + } + + #[test] + fn content_serve_matches_file_download() { + assert!(get("/files/8f8e4390-1234-4a5b-8c9d-abcdef012345")); + } + + #[test] + fn content_serve_matches_thumbnail() { + assert!(get("/files/8f8e4390-1234-4a5b-8c9d-abcdef012345/thumbnail/icon")); + assert!(get("/files/8f8e4390-1234-4a5b-8c9d-abcdef012345/thumbnail/large")); + } + + #[test] + fn content_serve_matches_folder_zip() { + assert!(get("/folders/8f8e4390-1234-4a5b-8c9d-abcdef012345/download")); + } + + #[test] + fn content_serve_matches_photo_preview() { + assert!(get("/photos/8f8e4390-1234-4a5b-8c9d-abcdef012345/preview")); + } + + #[test] + fn content_serve_rejects_non_get() { + // Attacker with stolen cookie can't mutate — POST/DELETE + // to the SAME url still requires DPoP. + assert!(!post("/files/8f8e4390-1234-4a5b-8c9d-abcdef012345")); + assert!(!is_content_serve_get( + &axum::http::Method::DELETE, + "/files/8f8e4390-1234-4a5b-8c9d-abcdef012345" + )); + } + + #[test] + fn content_serve_rejects_listing_endpoints() { + // Discovery endpoints (children, by-hash, search) MUST NOT be + // allowlisted — that's the whole security argument. Attacker + // needs to already know the UUID; can't enumerate. + assert!(!get("/folders/8f8e4390-1234-4a5b-8c9d-abcdef012345/children")); + assert!(!get("/files/by-hash")); + assert!(!get("/search")); + assert!(!get("/auth/me")); + } + + #[test] + fn content_serve_rejects_root_or_id_only() { + assert!(!get("/")); + assert!(!get("/files")); + assert!(!get("/folders")); + } } From a7653339b147afe4c4023391e1e66fc355fef551 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 9 Aug 2026 01:39:27 +0200 Subject: [PATCH 15/51] fix(dpop): sign XmlHttpReq + refresh --- frontend/src/lib/api/endpoints/auth.ts | 29 ++++- frontend/src/lib/api/endpoints/files.ts | 153 ++++++++++++++++-------- frontend/src/lib/auth/dpop-proof.ts | 10 +- 3 files changed, 142 insertions(+), 50 deletions(-) diff --git a/frontend/src/lib/api/endpoints/auth.ts b/frontend/src/lib/api/endpoints/auth.ts index d50408c2..23a4c8ef 100644 --- a/frontend/src/lib/api/endpoints/auth.ts +++ b/frontend/src/lib/api/endpoints/auth.ts @@ -64,15 +64,40 @@ export async function fetchMe(): Promise { * Attempt a single token refresh (raw fetch, no interceptor). Returns whether * it succeeded. Used by the startup probe; mid-session refresh is handled * transparently by apiFetch for all other endpoints. + * + * Mirrors `fetchMe`'s DPoP handling: dynamic-imports the proof module and + * attaches a signed proof so a bound session under `required` mode can still + * refresh on page reload. Falls back to a headerless refresh if the module + * is unavailable (unbound sessions still succeed; bound sessions in required + * mode won't — the documented fail-open contract in `docs/plan/dpop.md`). + * Retries ONCE on a `use_dpop_nonce` challenge so the very first request + * after a page load can adopt the freshly-issued nonce. */ export async function tryRefresh(): Promise { + let dpopMod: typeof import('$lib/auth/dpop-proof') | null = null; try { - const res = await fetch('/api/auth/refresh', { + dpopMod = await import('$lib/auth/dpop-proof'); + } catch { + /* no dpop module → plain fetch */ + } + const url = `${location.origin}/api/auth/refresh`; + const send = async (): Promise => { + const proof = dpopMod ? await dpopMod.buildDpopProof('POST', url).catch(() => null) : null; + const headers: HeadersInit = proof + ? { ...JSON_HEADERS, ...getCsrfHeaders(), DPoP: proof } + : { ...JSON_HEADERS, ...getCsrfHeaders() }; + const r = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'same-origin', - headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, + headers, body: '{}' }); + if (dpopMod) dpopMod.updateNonceFromResponse(r); + return r; + }; + try { + let res = await send(); + if (dpopMod && dpopMod.isDpopNonceChallenge(res)) res = await send(); return res.ok; } catch { return false; diff --git a/frontend/src/lib/api/endpoints/files.ts b/frontend/src/lib/api/endpoints/files.ts index fd6e37db..b233e4b5 100644 --- a/frontend/src/lib/api/endpoints/files.ts +++ b/frontend/src/lib/api/endpoints/files.ts @@ -62,61 +62,120 @@ export async function uploadFile(folderId: string | null, file: File): Promise void ): Promise { - return new Promise((resolve, reject) => { - const form = new FormData(); - if (folderId) form.append('folder_id', folderId); - form.append('file', file); - const xhr = new XMLHttpRequest(); - xhr.open('POST', '/api/files/upload'); - xhr.withCredentials = true; - for (const [k, v] of Object.entries(getCsrfHeaders())) xhr.setRequestHeader(k, v); + // Dynamic import — falls back to a headerless XHR if the DPoP module + // isn't loadable (SubtleCrypto disabled, IndexedDB blocked, etc.). + // Bound sessions in `required` mode still 401, but that's the fail- + // open contract already documented for other DPoP-aware raw callers + // (`fetchMe`). + let dpopMod: typeof import('$lib/auth/dpop-proof') | null = null; + try { + dpopMod = await import('$lib/auth/dpop-proof'); + } catch { + /* no dpop module → plain XHR */ + } + const url = `${location.origin}/api/files/upload`; - // Self-aborting watchdog so a stalled connection can never pin an upload - // slot forever (and leave a zombie XHR holding one of the browser's few - // per-host connections). While the body is uploading we reset the deadline - // on every progress tick — a slow but *moving* transfer is fine; once the - // body is fully sent we give the server a fixed window to respond. On a - // stall we `xhr.abort()`, which frees the connection immediately. - const SEND_STALL_MS = 30_000; - const RESPONSE_MS = 60_000; - let watchdog: ReturnType; - const arm = (ms: number) => { - clearTimeout(watchdog); - watchdog = setTimeout(() => xhr.abort(), ms); - }; + const attempt = (): Promise => + new Promise((resolve, reject) => { + const form = new FormData(); + if (folderId) form.append('folder_id', folderId); + form.append('file', file); + const xhr = new XMLHttpRequest(); + xhr.open('POST', '/api/files/upload'); + xhr.withCredentials = true; + for (const [k, v] of Object.entries(getCsrfHeaders())) xhr.setRequestHeader(k, v); - xhr.upload.onprogress = (e) => { - onProgress(e.lengthComputable ? e.loaded / e.total : NaN); - arm(SEND_STALL_MS); - }; - xhr.upload.onload = () => arm(RESPONSE_MS); // body sent — wait for the server - xhr.onload = () => { - clearTimeout(watchdog); - if (xhr.status >= 200 && xhr.status < 300) resolve(); - else { - // Flag quota so a batch can stop early instead of retrying every file. - const err = new Error(`upload failed: ${xhr.status}`) as Error & { isQuota?: boolean }; - err.isQuota = xhr.status === 507; - reject(err); + // Self-aborting watchdog so a stalled connection can never pin an upload + // slot forever (and leave a zombie XHR holding one of the browser's few + // per-host connections). While the body is uploading we reset the deadline + // on every progress tick — a slow but *moving* transfer is fine; once the + // body is fully sent we give the server a fixed window to respond. On a + // stall we `xhr.abort()`, which frees the connection immediately. + const SEND_STALL_MS = 30_000; + const RESPONSE_MS = 60_000; + let watchdog: ReturnType; + const arm = (ms: number) => { + clearTimeout(watchdog); + watchdog = setTimeout(() => xhr.abort(), ms); + }; + + const doSend = (proof: string | null) => { + if (proof) xhr.setRequestHeader('DPoP', proof); + xhr.upload.onprogress = (e) => { + onProgress(e.lengthComputable ? e.loaded / e.total : NaN); + arm(SEND_STALL_MS); + }; + xhr.upload.onload = () => arm(RESPONSE_MS); // body sent — wait for the server + xhr.onload = () => { + clearTimeout(watchdog); + // Sync the shared nonce cache from the response — the server + // rotates the nonce on every response, and other callers + // (apiFetch, fetchMe) share the same in-memory store. + if (dpopMod) dpopMod.updateNonceFromHeader(xhr.getResponseHeader('DPoP-Nonce')); + // Nonce challenge → surface a distinctive rejection so the outer + // retry can re-arm a fresh XHR (the current one has already + // consumed its request body). + if (xhr.status === 401 && /use_dpop_nonce/i.test(xhr.getResponseHeader('WWW-Authenticate') ?? '')) { + const err = new Error('dpop_nonce_challenge') as Error & { isNonceChallenge?: boolean }; + err.isNonceChallenge = true; + reject(err); + return; + } + if (xhr.status >= 200 && xhr.status < 300) resolve(); + else { + // Flag quota so a batch can stop early instead of retrying every file. + const err = new Error(`upload failed: ${xhr.status}`) as Error & { isQuota?: boolean }; + err.isQuota = xhr.status === 507; + reject(err); + } + }; + xhr.onerror = () => { + clearTimeout(watchdog); + reject(new Error('upload failed: network error')); + }; + xhr.onabort = () => { + clearTimeout(watchdog); + reject(new Error('upload stalled — aborted')); + }; + arm(SEND_STALL_MS); + xhr.send(form); + }; + + if (dpopMod) { + dpopMod + .buildDpopProof('POST', url) + .catch(() => null) + .then(doSend); + } else { + doSend(null); } - }; - xhr.onerror = () => { - clearTimeout(watchdog); - reject(new Error('upload failed: network error')); - }; - xhr.onabort = () => { - clearTimeout(watchdog); - reject(new Error('upload stalled — aborted')); - }; - arm(SEND_STALL_MS); - xhr.send(form); - }); + }); + + try { + await attempt(); + } catch (err) { + if ((err as { isNonceChallenge?: boolean } | null)?.isNonceChallenge) { + // Nonce was harvested by the failed attempt's onload; retry ONCE. + // A second challenge would loop, so any further failure surfaces. + await attempt(); + return; + } + throw err; + } } export async function renameFile(fileId: string, name: string): Promise { diff --git a/frontend/src/lib/auth/dpop-proof.ts b/frontend/src/lib/auth/dpop-proof.ts index 72610b08..2c74a5cb 100644 --- a/frontend/src/lib/auth/dpop-proof.ts +++ b/frontend/src/lib/auth/dpop-proof.ts @@ -37,7 +37,15 @@ function loadNonceOnce(): void { /** Update the nonce state from a fresh `DPoP-Nonce` response header. */ export function updateNonceFromResponse(response: Response): void { - const fresh = response.headers.get('DPoP-Nonce'); + updateNonceFromHeader(response.headers.get('DPoP-Nonce')); +} + +/** + * Update the nonce state from a raw header value — for callers that + * don't have a `fetch` `Response` (e.g. the `XMLHttpRequest` upload + * path, which needs XHR for upload-progress events). + */ +export function updateNonceFromHeader(fresh: string | null): void { if (!fresh || fresh === currentNonce) return; currentNonce = fresh; try { From 15da1a50bd8cf37c85ded06f7c6817adbdd8cd11 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sat, 8 Aug 2026 23:20:07 +0200 Subject: [PATCH 16/51] fix(dpop): fix issue with sveltekit and playwright await page.waitForLoadState('networkidle') is the key before starting --- frontend/src/lib/api/endpoints/auth.ts | 35 +++++++++++++++++---- tests/e2e/playwright.config.ts | 17 ++++++---- tests/e2e/playwright.coverage.config.ts | 21 +++++++++++++ tests/e2e/scenarios/helpers.ts | 41 +++++++++++++++++++------ 4 files changed, 92 insertions(+), 22 deletions(-) diff --git a/frontend/src/lib/api/endpoints/auth.ts b/frontend/src/lib/api/endpoints/auth.ts index 23a4c8ef..14c73d38 100644 --- a/frontend/src/lib/api/endpoints/auth.ts +++ b/frontend/src/lib/api/endpoints/auth.ts @@ -46,15 +46,38 @@ const JSON_HEADERS = { 'Content-Type': 'application/json' }; * headerless request — the server still accepts it for unbound sessions. */ export async function fetchMe(): Promise { - let dpop: string | null = null; + // Build + sign a DPoP proof, send with the header, harvest any + // `DPoP-Nonce` off the response into the shared client cache + // (so the NEXT apiFetch call reuses it — no wasted round trip). + // Handle the `use_dpop_nonce` challenge inline: the first request + // per fresh session has no cached nonce, and Gate 9 required-mode + // middleware 401-challenges a bound session's very first proof so + // the client picks up a fresh nonce. Without this retry, `/api/auth/me` + // on a fresh page load would always 401 → SPA thinks user isn't + // logged in → stuck on /login even though cookies are valid. + // + // Falls back to a plain fetch when the DPoP module is unavailable + // (SubtleCrypto disabled, IndexedDB blocked): unbound sessions + // still authenticate; bound sessions in required mode won't, but + // that's the fail-open contract from `docs/plan/dpop.md`. + let dpopMod: typeof import('$lib/auth/dpop-proof') | null = null; try { - const { buildDpopProof } = await import('$lib/auth/dpop-proof'); - dpop = await buildDpopProof('GET', `${location.origin}/api/auth/me`); + dpopMod = await import('$lib/auth/dpop-proof'); } catch { - /* proof unavailable → send without header; unbound sessions still accept */ + /* no dpop module → plain fetch */ } - const headers: HeadersInit = dpop ? { DPoP: dpop } : {}; - const res = await fetch('/api/auth/me', { credentials: 'same-origin', headers }); + const url = `${location.origin}/api/auth/me`; + const send = async (): Promise => { + const proof = dpopMod ? await dpopMod.buildDpopProof('GET', url).catch(() => null) : null; + const headers: HeadersInit = proof ? { DPoP: proof } : {}; + const r = await fetch('/api/auth/me', { credentials: 'same-origin', headers }); + if (dpopMod) dpopMod.updateNonceFromResponse(r); + return r; + }; + let res = await send(); + // One retry on nonce challenge — mirror the apiFetch interceptor. + // A second challenge on the retry is a server bug; surface the 401. + if (dpopMod && dpopMod.isDpopNonceChallenge(res)) res = await send(); if (res.status === 401) return null; if (!res.ok) throw new Error(`/api/auth/me failed: ${res.status}`); return (await res.json()) as User; diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index f11a1547..59fa353c 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -66,14 +66,19 @@ export default defineConfig({ // Verbose startup so a CI webServer-readiness timeout shows where the // server stalls (DB connect, migrations, bind) instead of nothing. RUST_LOG: 'info,oxicloud=debug,sqlx=warn,tower_http=info', - // OPAQUE + DPoP are inherited from `../common/server.env`: + // OPAQUE + DPoP inherited from `../common/server.env`: // OXICLOUD_AUTH_OPAQUE_MODE=migrate (Phase 2 silent-migration // on first legacy login, Phase 4 refusal thereafter) - // OXICLOUD_DPOP_MODE=required (verify every proof; unbound - // sessions still exempt per Gate 5 design) - // Testing under the production shape catches breakage where the - // SPA's fetch interceptor or the migration hook regresses in - // ways that only surface in a real browser + real crypto. + // OXICLOUD_DPOP_MODE=required (verify every proof; + // unbound sessions still exempt per Gate 5 design) + // + // Known failure surfaces under `DPOP=required`: + // * Node-side `page.request.*` helpers can't sign proofs + // → 401 on state-changing calls. Task #47 rewrites those + // through `page.evaluate` so signing happens in-browser. + // * Browser-direct content GETs (img src, a href, video src) + // also can't sign — Gate C content-serve allowlist in + // `middleware/dpop.rs` exempts the known paths. }, }, }); diff --git a/tests/e2e/playwright.coverage.config.ts b/tests/e2e/playwright.coverage.config.ts index f25ffb5c..269b4d00 100644 --- a/tests/e2e/playwright.coverage.config.ts +++ b/tests/e2e/playwright.coverage.config.ts @@ -73,6 +73,27 @@ export default defineConfig({ // `effective_mode == Off` short-circuit path. OXICLOUD_AUTH_OPAQUE_MODE: 'off', OXICLOUD_AUTH_OPAQUE_SERVER_SETUP: '', + // DPoP `opportunistic` — SPA browser flows still exercise the + // full wire protocol (proof signing + server verification + + // nonce challenge/retry + replay cache). The only weakening + // vs production `required` is that BOUND session + MISSING + // proof gets a warning-only pass instead of 401. + // + // Why not required: Node-side `page.request.*` test helpers + // (apiCreateFolder, apiAdminCreateUser, apiUploadFile, …) + // can't sign DPoP proofs because the browser's keypair is + // non-extractable by design. Under `required`, every helper + // POST/PUT/DELETE 401s and most tests fail at beforeEach. + // + // The missing-proof-on-bound-session enforcement IS covered + // end-to-end by `dpop-hurl-helper` scenario 9 under + // `tests/api/run.sh` (which keeps required from server.env), + // so global enforcement coverage is preserved. + // + // Task #47 tracks rewriting the helpers through page.evaluate + // so they can sign proofs in-browser. Once landed, this + // override goes and Playwright runs production-shape. + OXICLOUD_DPOP_MODE: 'opportunistic', }, }, }); diff --git a/tests/e2e/scenarios/helpers.ts b/tests/e2e/scenarios/helpers.ts index c3703b8e..ec1721f1 100644 --- a/tests/e2e/scenarios/helpers.ts +++ b/tests/e2e/scenarios/helpers.ts @@ -142,17 +142,38 @@ export async function apiLogin(page: Page, admin = TEST_ADMIN): Promise { } await page.goto('/login'); - await page.locator('[data-testid="login-username-input"]').fill(admin.username); - await page.locator('[data-testid="login-password-input"]').fill(admin.password); - await page.locator('[data-testid="login-submit-btn"]').click(); + // Wait for the SPA's boot probes (`getOidcProviders` + + // `getAuthStatus` in `login/+page.svelte::onMount`) to complete + // BEFORE touching the form. Otherwise the boot `$effect` fires + // MID-FILL — when `booting` flips from true to false, the + // auto-focus effect steals focus back to the identifier input, + // and any remaining characters of the password-fill land in + // the username field. Symptom: username="adminTestPassword1!", + // password="", submit-button shows "Send sign-in link" → SPA + // fires magic-link/send with the concatenated identifier and + // login never completes. + // + // `networkidle` waits for the network to have no more than 0 + // requests in flight for 500 ms. By that point providers + // + status have landed and `booting = false` has already + // stabilised → the auto-focus effect fired ONCE (harmlessly, + // before we touch the form), never again during our fills. + await page.waitForLoadState('networkidle'); + await page.getByTestId('login-username-input').fill(admin.username); + await page.getByTestId('login-password-input').fill(admin.password); + await page.getByTestId('login-submit-btn').click(); // Post-login the SPA's `goto(redirectTarget)` sends the user - // to `/files` (default) or a `?redirect=` target. Match the - // default with a glob — the same shape `uiLogin` uses in - // `spa/coverage-helpers.ts` and that Playwright handles well - // under SvelteKit's client-side navigation. The 15s ceiling - // covers the OPAQUE-post-migration path: WASM load + KE1 + - // KE3 + Argon2id. - await page.waitForURL('**/files**', { timeout: 15_000 }); + // to `/files` (default) or a `?redirect=` target — OR to + // `/profile?forcePasswordChange=1` when the backend has stamped + // `force_password_change_at_next_login=true` on this account + // (usually because a prior admin-reset test flipped it). Match + // any post-login destination that ISN'T `/login` itself. The + // 15s ceiling covers the OPAQUE-post-migration path: WASM load + // + KE1 + KE3 + Argon2id. + await page.waitForURL((url) => !url.pathname.startsWith('/login'), { + timeout: 15_000, + waitUntil: 'commit' + }); } /** From 34d80d70b8b365038c719a80625a3280d6424cc5 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 9 Aug 2026 01:54:59 +0200 Subject: [PATCH 17/51] feat(dpop): release route that must not dpop protected (temp version) --- src/interfaces/middleware/dpop.rs | 105 +++++++++++++++++++++++------- 1 file changed, 82 insertions(+), 23 deletions(-) diff --git a/src/interfaces/middleware/dpop.rs b/src/interfaces/middleware/dpop.rs index 4587e2b1..60df48ef 100644 --- a/src/interfaces/middleware/dpop.rs +++ b/src/interfaces/middleware/dpop.rs @@ -85,29 +85,37 @@ fn is_content_serve_get(method: &axum::http::Method, path: &str) -> bool { } fn matches_content_path(path: &str) -> bool { - // Segment-based matching keeps this cheap and readable — no regex - // dep, no leading-slash surprises. Every content endpoint has an - // opaque UUID at position 2, so we match on the shape rather than - // the specific id. The UUID check is what disambiguates + // Collect segments into a fixed stack buffer, then match all + // allowed shapes as slice patterns — reads left-to-right like + // the paths themselves. Any path deeper than the buffer or not + // matching a listed shape falls through to `false`. No alloc, + // no regex, no leading-slash surprises. + // + // UUID guards (`if looks_like_uuid(id)`) disambiguate // `/files/` (content) from `/files/by-hash` (a listing // endpoint that MUST stay behind DPoP for anti-enumeration). - let mut segs = path.trim_start_matches('/').split('/'); - let (Some(root), Some(id), rest_first) = (segs.next(), segs.next(), segs.next()) else { - return false; - }; - if !looks_like_uuid(id) { - return false; + // Plugin slugs aren't UUIDs so the SSE arm just accepts any + // segment there — the endpoint enforces its own admin AuthZ. + let mut buf: [&str; 6] = [""; 6]; + let mut n = 0; + for seg in path.trim_start_matches('/').split('/') { + if n == buf.len() { + return false; + } + buf[n] = seg; + n += 1; } - match (root, rest_first) { - // /files/ — download / inline - ("files", None) => true, - // /files//thumbnail/... — thumbnails - ("files", Some("thumbnail")) => true, - // /folders//download — zip - ("folders", Some("download")) => true, - // /photos//preview — photo preview (best-effort match; - // adds no risk if the endpoint doesn't exist server-side) - ("photos", Some("preview")) => true, + match &buf[..n] { + // SSE: EventSource can't attach headers → cookie-only auth + // (RFC 9449 known gap for streaming). Same posture as content- + // serve — attacker still needs the exact target id. + ["admin", "plugins", _id, "logs", "stream"] => true, + // Content-serve GETs — browser fetches directly from + // ``, `img src`, `video src`, ``. + ["files", id] if looks_like_uuid(id) => true, + ["files", id, "thumbnail", _size] if looks_like_uuid(id) => true, + ["folders", id, "download"] if looks_like_uuid(id) => true, + ["photos", id, "preview"] if looks_like_uuid(id) => true, _ => false, } } @@ -193,17 +201,44 @@ pub async fn require_dpop_layer( // warning-only signal in opportunistic mode so operators // can spot stale SPA versions before flipping enforcement. let expected_jkt = current_user.dpop_jkt.as_deref(); + // Diagnostic fields shared by both branches — `referer` is + // usually the smoking gun for "which SPA page sent this?"; + // `user_agent` helps distinguish SPA (`Mozilla/…`), Node-side + // Playwright helper (`node`), and legacy client (blank). + let req_method = request.method().to_string(); + let req_path = request.uri().path().to_owned(); + let req_referer = request + .headers() + .get("referer") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_owned(); + let req_user_agent = request + .headers() + .get("user-agent") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_owned(); let Some(proof) = dpop_header else { match (mode, expected_jkt) { (DpopMode::Required, Some(_)) if !is_content_serve_get(request.method(), request.uri().path()) => { + // Distinct event name: `dpop.proof_missing` means + // no header was on the wire at all — no verification + // happened. `dpop.verify_failed` is reserved for + // proofs that WERE present but failed cryptographic + // or claim checks. Log aggregators key off `event` + // separately from `reason`, so the split matters. tracing::info!( target: "audit", - event = "dpop.verify_failed", + event = "dpop.proof_missing", reason = "proof_missing_on_bound_session", caller_id = %current_user.id, - path = %request.uri().path(), + method = %req_method, + path = %req_path, + referer = %req_referer, + user_agent = %req_user_agent, "👮🏻‍♂️ DPoP required: bound session request has no proof", ); return nonce_challenge_response(&nonce_service); @@ -217,7 +252,10 @@ pub async fn require_dpop_layer( target: "audit", event = "dpop.header_missing_but_session_bound", caller_id = %current_user.id, - path = %request.uri().path(), + method = %req_method, + path = %req_path, + referer = %req_referer, + user_agent = %req_user_agent, "⚠️ DPoP: bound session sent request without a proof", ); } @@ -504,4 +542,25 @@ mod tests { assert!(!get("/files")); assert!(!get("/folders")); } + + #[test] + fn content_serve_matches_plugin_log_sse_stream() { + // Server-Sent Events endpoint. Browser uses EventSource, + // which can't attach custom headers — cookie-only auth is + // the RFC 9449 known gap for streaming. Allowlisted. + assert!(get("/admin/plugins/com.example.hello/logs/stream")); + assert!(get("/admin/plugins/some-slug/logs/stream")); + } + + #[test] + fn content_serve_rejects_other_admin_plugin_endpoints() { + // Only the SSE stream is allowlisted. Every other plugin + // endpoint (list, install, uninstall, config) stays behind + // DPoP for anti-enumeration and mutation protection. + assert!(!get("/admin/plugins")); + assert!(!get("/admin/plugins/some-slug")); + assert!(!get("/admin/plugins/some-slug/logs")); + assert!(!get("/admin/plugins/some-slug/config")); + assert!(!post("/admin/plugins/some-slug/logs/stream")); + } } From 97a56899df88d93abdb26bcccd0fc50c649f46ad Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 9 Aug 2026 02:02:20 +0200 Subject: [PATCH 18/51] refactor(dpop): apply prettier --- frontend/src/lib/api/endpoints/auth.test.ts | 16 +++++++++++++--- frontend/src/lib/api/endpoints/files.ts | 5 ++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/api/endpoints/auth.test.ts b/frontend/src/lib/api/endpoints/auth.test.ts index 2fae11a7..648f9c6d 100644 --- a/frontend/src/lib/api/endpoints/auth.test.ts +++ b/frontend/src/lib/api/endpoints/auth.test.ts @@ -35,7 +35,10 @@ import { __resetOpaqueParamsCache } from './opaque'; const f = apiFetch as unknown as ReturnType; const j = apiJson as unknown as ReturnType; // Several auth probes use the raw global fetch (NOT apiFetch) on purpose. -const okRes = { ok: true, status: 200, json: async () => ({}) }; +// `headers: new Headers()` matches the real `fetch()` contract — several +// probes (fetchMe, tryRefresh) run through the DPoP nonce-update shim, +// which calls `response.headers.get('DPoP-Nonce')` on every reply. +const okRes = { ok: true, status: 200, headers: new Headers(), json: async () => ({}) }; beforeEach(() => { vi.clearAllMocks(); // login() dynamically imports the OPAQUE client and calls @@ -63,16 +66,23 @@ it('exercises the auth endpoints (success paths)', async () => { expect(fc + f.mock.calls.length).toBeGreaterThan(3); }); it('fetchMe returns null when the probe is not ok', async () => { + // `headers: new Headers()` matches real `fetch()` — the DPoP-aware + // path calls `response.headers.get('DPoP-Nonce')` on every reply + // and would crash on a bare `{ok, status, json}` mock. vi.stubGlobal( 'fetch', - vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({}) }) + vi + .fn() + .mockResolvedValue({ ok: false, status: 401, headers: new Headers(), json: async () => ({}) }) ); await expect(auth.fetchMe()).resolves.toBeNull(); }); it('tryRefresh returns false when the refresh fails', async () => { vi.stubGlobal( 'fetch', - vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({}) }) + vi + .fn() + .mockResolvedValue({ ok: false, status: 401, headers: new Headers(), json: async () => ({}) }) ); await expect(auth.tryRefresh()).resolves.toBe(false); }); diff --git a/frontend/src/lib/api/endpoints/files.ts b/frontend/src/lib/api/endpoints/files.ts index b233e4b5..245beb99 100644 --- a/frontend/src/lib/api/endpoints/files.ts +++ b/frontend/src/lib/api/endpoints/files.ts @@ -129,7 +129,10 @@ export async function uploadFileWithProgress( // Nonce challenge → surface a distinctive rejection so the outer // retry can re-arm a fresh XHR (the current one has already // consumed its request body). - if (xhr.status === 401 && /use_dpop_nonce/i.test(xhr.getResponseHeader('WWW-Authenticate') ?? '')) { + if ( + xhr.status === 401 && + /use_dpop_nonce/i.test(xhr.getResponseHeader('WWW-Authenticate') ?? '') + ) { const err = new Error('dpop_nonce_challenge') as Error & { isNonceChallenge?: boolean }; err.isNonceChallenge = true; reject(err); From d0712817c5a78411b6ba0a2e19d2f29b5f4254bf Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 9 Aug 2026 02:03:54 +0200 Subject: [PATCH 19/51] refactor(dpop): apply clippy --- examples/bench_nc_session.rs | 1 + examples/bench_round10_micro.rs | 3 +++ .../services/auth_application_service.rs | 4 ++++ src/interfaces/middleware/dpop.rs | 16 ++++++++++++---- 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/examples/bench_nc_session.rs b/examples/bench_nc_session.rs index 01077915..5d7aed13 100644 --- a/examples/bench_nc_session.rs +++ b/examples/bench_nc_session.rs @@ -122,6 +122,7 @@ fn fixture_user(id: uuid::Uuid) -> CurrentUser { username: Arc::from("alice.longname"), email: Arc::from("alice.longname@example.com"), role: smol_str::SmolStr::new_static("user"), + dpop_jkt: None, } } diff --git a/examples/bench_round10_micro.rs b/examples/bench_round10_micro.rs index 6e09eb41..8b884d8a 100644 --- a/examples/bench_round10_micro.rs +++ b/examples/bench_round10_micro.rs @@ -158,6 +158,7 @@ fn section_identity(iters: u64) { username: Arc::from("alice.longname"), email: Arc::from("alice.longname@example.com"), role: "user".to_string(), + dpop_jkt: None, }); let (bn, ba) = measure("BEFORE String clones + role to_string", iters, || { @@ -171,6 +172,7 @@ fn section_identity(iters: u64) { username: Arc::clone(&black_box(&new_claims).username), email: Arc::clone(&new_claims.email), role, + dpop_jkt: None, }) }); @@ -181,6 +183,7 @@ fn section_identity(iters: u64) { username: Arc::clone(&new_claims.username), email: Arc::clone(&new_claims.email), role: SmolStr::new_static("user"), + dpop_jkt: None, }); assert_eq!(old.username, *new.username); assert_eq!(old.email, *new.email); diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 533c33b5..31abe677 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -4495,6 +4495,7 @@ mod phase4_gate_integration_tests { svc.login(crate::application::dtos::user_dto::LoginDto { username: email.clone(), password: "s3cret-passphrase".to_string(), + dpop_jkt: None, }) .await .expect("baseline legacy login must succeed"); @@ -4512,6 +4513,7 @@ mod phase4_gate_integration_tests { .login(crate::application::dtos::user_dto::LoginDto { username: email.clone(), password: "s3cret-passphrase".to_string(), + dpop_jkt: None, }) .await .expect_err("legacy login must be refused post-migration"); @@ -4535,6 +4537,7 @@ mod phase4_gate_integration_tests { .login(crate::application::dtos::user_dto::LoginDto { username: email.clone(), password: "wrong-password".to_string(), + dpop_jkt: None, }) .await .expect_err("wrong password must still fail"); @@ -4551,6 +4554,7 @@ mod phase4_gate_integration_tests { svc.login(crate::application::dtos::user_dto::LoginDto { username: email, password: "s3cret-passphrase".to_string(), + dpop_jkt: None, }) .await .expect("legacy login must succeed again after admin clear_registration"); diff --git a/src/interfaces/middleware/dpop.rs b/src/interfaces/middleware/dpop.rs index 60df48ef..1b33c24c 100644 --- a/src/interfaces/middleware/dpop.rs +++ b/src/interfaces/middleware/dpop.rs @@ -500,13 +500,19 @@ mod tests { #[test] fn content_serve_matches_thumbnail() { - assert!(get("/files/8f8e4390-1234-4a5b-8c9d-abcdef012345/thumbnail/icon")); - assert!(get("/files/8f8e4390-1234-4a5b-8c9d-abcdef012345/thumbnail/large")); + assert!(get( + "/files/8f8e4390-1234-4a5b-8c9d-abcdef012345/thumbnail/icon" + )); + assert!(get( + "/files/8f8e4390-1234-4a5b-8c9d-abcdef012345/thumbnail/large" + )); } #[test] fn content_serve_matches_folder_zip() { - assert!(get("/folders/8f8e4390-1234-4a5b-8c9d-abcdef012345/download")); + assert!(get( + "/folders/8f8e4390-1234-4a5b-8c9d-abcdef012345/download" + )); } #[test] @@ -530,7 +536,9 @@ mod tests { // Discovery endpoints (children, by-hash, search) MUST NOT be // allowlisted — that's the whole security argument. Attacker // needs to already know the UUID; can't enumerate. - assert!(!get("/folders/8f8e4390-1234-4a5b-8c9d-abcdef012345/children")); + assert!(!get( + "/folders/8f8e4390-1234-4a5b-8c9d-abcdef012345/children" + )); assert!(!get("/files/by-hash")); assert!(!get("/search")); assert!(!get("/auth/me")); From a218199a02068d6ac79d8630c7625942cefb4dd7 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 9 Aug 2026 02:18:31 +0200 Subject: [PATCH 20/51] plan(dpop): remind the choice of opened GET path --- docs/plan/dpop.md | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/docs/plan/dpop.md b/docs/plan/dpop.md index 4b2ae7bb..4bfd2e75 100644 --- a/docs/plan/dpop.md +++ b/docs/plan/dpop.md @@ -221,6 +221,31 @@ DPoP (RFC 9449) closes this gap by binding the session to a browser-held private - After 2-4 weeks of clean opportunistic-mode telemetry, flip default to `required` in a later release. Pre-existing app-password / legacy sessions with `dpop_jkt IS NULL` still work; they're exempt at the middleware. - **Documentation deliverable**: operator guide entry explaining the flag, the modes, the observability signals, the upgrade path. +## Gate C — Content-serve + streaming allowlist (browser-direct GETs) + +Discovered during the `required`-mode rollout: some SPA endpoints are fetched by the **browser itself**, not by JS via `fetch()`. These paths have no JS in the loop to sign a DPoP proof: + +- `` — thumbnails, photo previews. +- `` — file downloads, folder ZIP downloads. +- `` — file inline previews. +- `EventSource` — streaming endpoints (RFC 9449 known gap: `EventSource` cannot set custom request headers, only cookies). + +Without a carve-out, flipping to `DPOP=required` breaks all of these on bound sessions. Ships a middleware allowlist keyed on `(method, path)` that exempts these specific shapes from the missing-proof reject: + +- `GET /api/files/{uuid}` — download / inline +- `GET /api/files/{uuid}/thumbnail/{size}` — thumbnails +- `GET /api/folders/{uuid}/download` — zip +- `GET /api/photos/{uuid}/preview` — preview (best-effort; adds no risk if the endpoint doesn't exist) +- `GET /api/admin/plugins/{id}/logs/stream` — plugin log tail SSE + +The allowlist exempts ONLY the missing-proof reject. Proofs that ARE sent on these paths (e.g. an SPA image preloader that went through `apiFetch` and blob'd) still get fully verified. + +**Security posture (accepted trade-off).** An attacker with a stolen cookie can GET one of these URLs *if and only if* they already know a specific 128-bit UUID. Every listing / discovery endpoint (`/api/folders/{id}/children`, `/api/photos`, `/api/files/by-hash`, `/search`, …) still requires a DPoP proof — those go through `apiFetch`. So a bare stolen cookie gives the attacker "download the exact IDs you already know" — effectively nothing without prior knowledge. Plugin log SSE additionally has admin-only AuthZ at the handler. + +**Refactor pending (near-term).** The allowlist currently lives in the DPoP middleware as a slice-pattern matcher over path segments (`matches_content_path` in `src/interfaces/middleware/dpop.rs`). Cleaner: split the router — exempt routes on one sub-router without the `require_dpop_layer`, protected routes with it, merge. Declares exemption next to the route registration rather than centrally in the matcher; deletes the matcher and its 9 tests. Effort: 0.5 day. Behaviour-preserving. + +**Long-term evolution (Option B).** See Deferred to Phase 2 — signed short-lived URL tokens replace the allowlist entirely. + ## Gate 10 — Observability + admin UX - **Audit events** (all with `target: "audit"`): @@ -240,6 +265,7 @@ DPoP (RFC 9449) closes this gap by binding the session to a browser-held private - **Native Nextcloud client support** — no `SubtleCrypto`; would need embedded ECDSA + secure keystore (Android Keystore / iOS Keychain / OS keyring). Substantially larger project; the current Basic-Auth-over-app-password path stays unchanged. - **Attested keys via WebAuthn** — bind to TPM / Secure Enclave. Blocks the login-time-compromised-browser attack. Major UX shift (per-request user gesture unless resident-key + silent-assertion flows mature). - **Detect DPoP capability on upstream IdP** — parse `.well-known/openid-configuration`, warn at boot when `dpop_signing_alg_values_supported` is absent. Half-day of work, orthogonal to this plan, worth its own tiny PR. +- **Signed short-lived URL tokens for content-serve paths (Option B, replaces Gate C)**. The SPA (through `apiFetch`, so DPoP-verified) mints a per-user, per-URL token like `?dl_token=` for each browser-direct URL; the server accepts EITHER a valid DPoP proof OR a valid short-lived token (~5 min TTL, HMAC over `(user_id, path, expiry)`). Attacker with a stolen cookie loses the ability to GET any content path because tokens are user-scoped and expire fast — removes the "known UUID = downloadable" trade-off Gate C accepts today. Retires the Gate C allowlist entirely (plus its router-split follow-up). Effort: ~2-3 person-days (token mint endpoint + verifier middleware + SPA URL rewriter for ``/``/EventSource URLs). --- @@ -259,8 +285,9 @@ DPoP (RFC 9449) closes this gap by binding the session to a browser-held private | 7 — Refresh + logout | 1 day | | 8 — Multi-tab | 0.5 day | | 9 — Rollout | 0 (calendar time, no engineering work) | +| C — Content-serve + streaming allowlist | 0.5 day (shipped alongside Gate 9) | | 10 — Observability + admin UX | 1 day | -| **Total (Phase 1)** | **~11.5 person-days** | +| **Total (Phase 1)** | **~12 person-days** | ## Risks worth naming From 7529914e321616daa8b5c82b684764c13abaed1c Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 9 Aug 2026 02:25:43 +0200 Subject: [PATCH 21/51] feat(dpop): test XHR request + test session refresh --- frontend/src/lib/api/endpoints/auth.test.ts | 104 +++++++++- frontend/src/lib/api/endpoints/files.test.ts | 198 ++++++++++++++++++- 2 files changed, 299 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/api/endpoints/auth.test.ts b/frontend/src/lib/api/endpoints/auth.test.ts index 648f9c6d..8bc301f3 100644 --- a/frontend/src/lib/api/endpoints/auth.test.ts +++ b/frontend/src/lib/api/endpoints/auth.test.ts @@ -1,6 +1,19 @@ -import { it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() })); vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) })); +// Several probes (`fetchMe`, `tryRefresh`) run through the DPoP shim. +// Default `proof = null` matches jsdom's missing WebCrypto keys — so +// pre-existing suite behaviour is unchanged. Individual tests flip the +// state to a non-null value to exercise the DPoP-attached path. +const dpopState = vi.hoisted(() => ({ proof: null as string | null })); +vi.mock('$lib/auth/dpop-proof', async () => { + const actual = + await vi.importActual('$lib/auth/dpop-proof'); + return { + ...actual, + buildDpopProof: vi.fn(async () => dpopState.proof) + }; +}); // Mock the OPAQUE WASM client so `login()`'s Phase 2 silent-migration // hook and Phase 3 lookup-then-login flip can exercise the wire path // (params + lookup + register or ke1/ke3 handshake) without touching @@ -46,6 +59,9 @@ beforeEach(() => { // module-level singleton. Reset it so each test's mock // responses drive a fresh /params fetch. __resetOpaqueParamsCache(); + // Default: DPoP proof unavailable (matches jsdom's missing WebCrypto). + // DPoP-aware describe blocks below opt in by setting a proof value. + dpopState.proof = null; f.mockResolvedValue(okRes); j.mockResolvedValue({}); vi.stubGlobal('fetch', vi.fn().mockResolvedValue(okRes)); @@ -432,3 +448,89 @@ it('login returns AuthResponse even when silent-migration fails (non-fatal)', as expect(authResponse.access_token).toBe('at'); consoleSpy.mockRestore(); }); + +// ── tryRefresh — DPoP wiring ───────────────────────────────────────── +// +// Startup probe (raw `fetch`, NOT apiFetch) that must still authenticate +// under `DPOP=required`: page reloads with a bound session + expired +// access token would otherwise 401. Mirrors `fetchMe`'s DPoP handling: +// dynamic-import proof module, attach `DPoP` header, harvest response +// nonce into the shared cache, retry ONCE on `use_dpop_nonce`. +// +// Regression risks these tests guard: +// - Proof stops being attached → bound sessions can't refresh. +// - Nonce not harvested → the next request 401s with `nonce_missing`. +// - Second challenge loops (would burn cycles and mask a real server +// bug behind an infinite retry). +describe('tryRefresh — DPoP wiring', () => { + const okRefreshRes = () => ({ + ok: true, + status: 200, + headers: new Headers(), + json: async () => ({}) + }); + const nonceChallenge = () => ({ + ok: false, + status: 401, + headers: new Headers({ + 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"', + 'DPoP-Nonce': 'srv-fresh' + }), + json: async () => ({}) + }); + + beforeEach(() => { + // Opt into DPoP-attached behaviour for this block; individual + // tests can still flip it back to null to check the fail-open. + dpopState.proof = 'proof.abc'; + }); + + it('attaches a DPoP header on the refresh POST', async () => { + const spy = vi.fn().mockResolvedValue(okRefreshRes()); + vi.stubGlobal('fetch', spy); + + const ok = await auth.tryRefresh(); + expect(ok).toBe(true); + + const [url, init] = spy.mock.calls[0]; + expect(url).toBe('/api/auth/refresh'); + const hdrs = new Headers((init as RequestInit).headers ?? {}); + expect(hdrs.get('DPoP')).toBe('proof.abc'); + }); + + it('sends no DPoP header when the proof module has no keypair (fail-open)', async () => { + dpopState.proof = null; + const spy = vi.fn().mockResolvedValue(okRefreshRes()); + vi.stubGlobal('fetch', spy); + + await auth.tryRefresh(); + + const [, init] = spy.mock.calls[0]; + const hdrs = new Headers((init as RequestInit).headers ?? {}); + expect(hdrs.get('DPoP')).toBeNull(); + }); + + it('retries once on a use_dpop_nonce challenge, then succeeds', async () => { + const spy = vi + .fn() + .mockResolvedValueOnce(nonceChallenge()) + .mockResolvedValueOnce(okRefreshRes()); + vi.stubGlobal('fetch', spy); + + const ok = await auth.tryRefresh(); + expect(ok).toBe(true); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('does not loop when the retry ALSO returns use_dpop_nonce', async () => { + // A second challenge would indicate a server-side nonce bug; + // tryRefresh must surface it as a plain refresh failure rather + // than looping forever. + const spy = vi.fn().mockResolvedValue(nonceChallenge()); + vi.stubGlobal('fetch', spy); + + const ok = await auth.tryRefresh(); + expect(ok).toBe(false); + expect(spy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend/src/lib/api/endpoints/files.test.ts b/frontend/src/lib/api/endpoints/files.test.ts index 29a2c93f..06c00157 100644 --- a/frontend/src/lib/api/endpoints/files.test.ts +++ b/frontend/src/lib/api/endpoints/files.test.ts @@ -1,6 +1,19 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() })); vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) })); +// Controllable DPoP proof — `uploadFileWithProgress` uses XHR (needed +// for upload-progress events) and manually signs a proof. Default null +// so pre-existing tests are unaffected; the XHR-DPoP block below opts +// in. +const dpopState = vi.hoisted(() => ({ proof: null as string | null })); +vi.mock('$lib/auth/dpop-proof', async () => { + const actual = + await vi.importActual('$lib/auth/dpop-proof'); + return { + ...actual, + buildDpopProof: vi.fn(async () => dpopState.proof) + }; +}); import { apiFetch } from '$lib/api/client'; import { uploadFile, @@ -8,7 +21,8 @@ import { moveFile, deleteFile, fileDownloadUrl, - fileInlineUrl + fileInlineUrl, + uploadFileWithProgress } from './files'; const f = apiFetch as unknown as ReturnType; describe('files endpoint URL builders', () => { @@ -32,3 +46,183 @@ describe('files endpoint mutations', () => { expect(f).toHaveBeenCalled(); }); }); + +// ── uploadFileWithProgress — DPoP + XHR ────────────────────────────── +// +// The upload path uses raw XMLHttpRequest (fetch can't emit upload- +// progress events). That means it bypasses the `apiFetch` DPoP +// interceptor and has to sign a proof + handle a `use_dpop_nonce` +// challenge itself. These tests guard: +// - DPoP header is attached before send. +// - Progress callback fires from `upload.onprogress`. +// - Nonce is harvested from the response into the shared cache. +// - A `use_dpop_nonce` challenge on the first attempt triggers ONE +// retry (fresh XHR — the failed one already consumed its body). +// - A second challenge does NOT loop (would mask a server-side bug). +// - A 507 rejection carries `isQuota: true` for the batch orchestrator. + +/** + * Minimal `XMLHttpRequest` stand-in — implements only the surface + * `uploadFileWithProgress` touches. Tests drive it by calling + * `respond(status, headers)` / `fireProgress()` / `fireError()`. + */ +class MockXHR { + method = ''; + url = ''; + withCredentials = false; + requestHeaders = new Map(); + responseHeaders = new Map(); + status = 0; + body: unknown = null; + upload: { + onprogress: ((e: ProgressEvent) => void) | null; + onload: (() => void) | null; + } = { onprogress: null, onload: null }; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + onabort: (() => void) | null = null; + + open(method: string, url: string): void { + this.method = method; + this.url = url; + } + setRequestHeader(k: string, v: string): void { + this.requestHeaders.set(k, v); + } + send(body: unknown): void { + this.body = body; + } + abort(): void { + queueMicrotask(() => this.onabort?.()); + } + getResponseHeader(k: string): string | null { + return this.responseHeaders.get(k.toLowerCase()) ?? null; + } + // Test helpers + fireProgress(loaded: number, total: number): void { + this.upload.onprogress?.({ loaded, total, lengthComputable: true } as ProgressEvent); + } + respond(status: number, headers: Record = {}): void { + this.status = status; + for (const [k, v] of Object.entries(headers)) this.responseHeaders.set(k.toLowerCase(), v); + this.onload?.(); + } + fireError(): void { + this.onerror?.(); + } +} + +/** Yield to microtasks + timers so `uploadFileWithProgress`'s dynamic- + * import + `buildDpopProof` chain resolves and `xhr.send()` is reached. */ +async function waitForXhr(sink: MockXHR[], idx = 0, tries = 30): Promise { + for (let i = 0; i < tries; i++) { + if (sink[idx] && sink[idx].body !== null) return sink[idx]; + await new Promise((r) => setTimeout(r, 5)); + } + throw new Error(`XHR #${idx} never reached send() (had ${sink.length} instance(s))`); +} + +describe('uploadFileWithProgress — DPoP + XHR', () => { + let xhrs: MockXHR[]; + beforeEach(() => { + vi.clearAllMocks(); + dpopState.proof = 'proof.upload'; + xhrs = []; + const XhrStub = class extends MockXHR { + constructor() { + super(); + xhrs.push(this); + } + }; + vi.stubGlobal('XMLHttpRequest', XhrStub as unknown as typeof XMLHttpRequest); + }); + afterEach(() => vi.unstubAllGlobals()); + + it('attaches a DPoP header on the XHR and resolves on 2xx', async () => { + const file = new File([new Uint8Array([1, 2, 3])], 'a.txt'); + const onProgress = vi.fn(); + const p = uploadFileWithProgress('folder-1', file, onProgress); + const xhr = await waitForXhr(xhrs); + + expect(xhr.method).toBe('POST'); + expect(xhr.url).toBe('/api/files/upload'); + expect(xhr.requestHeaders.get('DPoP')).toBe('proof.upload'); + expect(xhr.withCredentials).toBe(true); + + // Simulate progress + successful completion. + xhr.fireProgress(1, 3); + xhr.fireProgress(3, 3); + xhr.respond(200); + await expect(p).resolves.toBeUndefined(); + expect(onProgress).toHaveBeenCalled(); + expect(onProgress).toHaveBeenLastCalledWith(1); + }); + + it('sends no DPoP header when the proof module has no keypair (fail-open)', async () => { + dpopState.proof = null; + const file = new File([new Uint8Array([1])], 'a.txt'); + const p = uploadFileWithProgress(null, file, () => {}); + const xhr = await waitForXhr(xhrs); + expect(xhr.requestHeaders.get('DPoP')).toBeUndefined(); + xhr.respond(200); + await p; + }); + + it('surfaces a 507 with isQuota flag for the batch orchestrator', async () => { + const file = new File([new Uint8Array([1])], 'a.txt'); + const p = uploadFileWithProgress(null, file, () => {}); + const xhr = await waitForXhr(xhrs); + xhr.respond(507); + await expect(p).rejects.toMatchObject({ + isQuota: true, + message: expect.stringContaining('507') + }); + }); + + it('rejects on network error', async () => { + const file = new File([new Uint8Array([1])], 'a.txt'); + const p = uploadFileWithProgress(null, file, () => {}); + const xhr = await waitForXhr(xhrs); + xhr.fireError(); + await expect(p).rejects.toThrow(/network error/); + }); + + it('retries once on a use_dpop_nonce challenge (fresh XHR)', async () => { + const file = new File([new Uint8Array([1])], 'a.txt'); + const p = uploadFileWithProgress(null, file, () => {}); + + // First XHR — server sends the DPoP-Nonce challenge. + const first = await waitForXhr(xhrs, 0); + first.respond(401, { + 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"', + 'DPoP-Nonce': 'srv-fresh' + }); + + // Retry mints a fresh XHR (the first one has already consumed + // its body); it must also carry the DPoP header. + const second = await waitForXhr(xhrs, 1); + expect(second.requestHeaders.get('DPoP')).toBe('proof.upload'); + second.respond(200); + await expect(p).resolves.toBeUndefined(); + expect(xhrs).toHaveLength(2); + }); + + it('does not loop when the retry ALSO returns use_dpop_nonce', async () => { + const file = new File([new Uint8Array([1])], 'a.txt'); + const p = uploadFileWithProgress(null, file, () => {}); + + const first = await waitForXhr(xhrs, 0); + first.respond(401, { + 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"', + 'DPoP-Nonce': 'srv-fresh' + }); + const second = await waitForXhr(xhrs, 1); + second.respond(401, { + 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"', + 'DPoP-Nonce': 'srv-fresher' + }); + + await expect(p).rejects.toThrow(/dpop_nonce_challenge/); + expect(xhrs).toHaveLength(2); // never a third + }); +}); From 93eb67f99bb6a282c607426c88cba61b02579293 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 9 Aug 2026 02:49:14 +0200 Subject: [PATCH 22/51] feat(dpop): add service worker to sign requests this will permit GET to resources and then to remove directly signing requests --- frontend/src/routes/+layout.svelte | 58 ++++++++++++++ frontend/src/service-worker.ts | 124 +++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 frontend/src/service-worker.ts diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 198dd264..e8d39ab5 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -82,6 +82,64 @@ await killLegacyServiceWorker(); + // Register the DPoP-signing Service Worker (see + // `src/service-worker.ts`). It attaches a DPoP proof to every + // same-origin `/api/*` request the browser initiates on its + // own — `` thumbnails, `` downloads, + // `EventSource` streams — replacing the server-side content + // allowlist (Gate C in `docs/plan/dpop.md`). + // + // `type: 'module'` matches SvelteKit's ESM output. Failure is + // swallowed to `console.debug`: unbound sessions and legacy + // browsers without SW support keep working; only bound sessions + // in `DPOP=required` mode see 401s on browser-direct URLs, + // which is the same failure mode as before this SW existed. + // + // AWAIT `navigator.serviceWorker.ready` before allowing the + // splash to lift on a first-ever visit. Without this, the SPA + // renders while the SW is still installing → thumbnails and + // downloads on that very first page load fire unsigned and + // 401. Second visit onward the SW is already installed, so + // `.ready` resolves in ~1ms — the wait is a one-shot cost per + // browser profile. + if ('serviceWorker' in navigator) { + void navigator.serviceWorker + .register('/service-worker.js', { type: 'module' }) + .catch((err) => console.debug('DPoP service worker registration failed', err)); + try { + await navigator.serviceWorker.ready; + } catch { + /* SW registration failed — unbound sessions still work; + bound sessions in `required` mode see 401s on browser- + direct URLs. Same fail-open shape as pre-SW. */ + } + // Soft-reload once if we landed uncontrolled. Covers two + // cases the SW itself can't fix: + // * First-ever visit — page loaded before any SW existed, + // so `controller` is null even after `.ready`. + // * Force-refresh (Cmd-Shift-R) — the browser deliberately + // delivers the top-level document with no SW controller; + // `clients.claim()` can't attach retroactively. + // A single reload brings the page under SW control. + // + // `sessionStorage` guard prevents an infinite loop when the + // reload doesn't fix it (SW registration is genuinely broken + // — bad URL, CSP block, quota). Cleared once `controller` is + // set so a LATER force-refresh in the same session can also + // self-heal (one reload per uncontrolled-landing). + const SW_RELOAD_KEY = 'sw-reload-once'; + if (!navigator.serviceWorker.controller) { + if (!sessionStorage.getItem(SW_RELOAD_KEY)) { + sessionStorage.setItem(SW_RELOAD_KEY, '1'); + location.reload(); + return; + } + /* reload already attempted this session — give up gracefully */ + } else { + sessionStorage.removeItem(SW_RELOAD_KEY); + } + } + // The instant HTML boot splash has done its job — the app is mounted, so // the route (login renders immediately; protected routes show their own // loading state) is already in the DOM behind it. diff --git a/frontend/src/service-worker.ts b/frontend/src/service-worker.ts new file mode 100644 index 00000000..8818d820 --- /dev/null +++ b/frontend/src/service-worker.ts @@ -0,0 +1,124 @@ +/// +/// + +/** + * DPoP-signing Service Worker (RFC 9449 companion to the page-context + * `apiFetch` interceptor). + * + * WHY this exists. Under `OXICLOUD_DPOP_MODE=required` the server rejects + * bound-session requests that carry no DPoP proof. The page-context + * `apiFetch` interceptor covers everything the SPA drives through + * `fetch()` — but the browser itself makes requests JS can never touch: + * `` (thumbnails, photo previews), `` / + * `` (file downloads / inline previews), and `EventSource` + * (admin log tail). Those had lived behind a middleware allowlist + * (Gate C) — this SW replaces that allowlist entirely by attaching a + * proof to every same-origin `/api/*` request the browser makes, + * regardless of who initiated it. + * + * Why NOT a Web Worker. Dedicated workers can't see network requests + * the page initiates. Only Service Workers register a `fetch` handler + * for their scope. This IS the browser-side hook for browser-driven + * requests. + * + * Shared state with the page. Both scopes share the same-origin + * IndexedDB (where the persistent P-256 keypair lives) and SubtleCrypto + * (also available in SW context). The nonce cache is per-scope — the + * page module holds its own in-memory nonce, the SW holds its own; on + * first request each scope pays a one-round-trip nonce challenge, then + * catches up via `DPoP-Nonce` response headers. + * + * Skip conditions: + * * cross-origin (privacy — never leak the user's keypair thumbprint + * to third parties); + * * anything outside `/api/*` (static assets don't hit the DPoP + * middleware, no need to burn crypto per request); + * * requests that already carry a `DPoP` header — the page context + * signed them via `apiFetch` (or the XHR upload path), don't + * double-sign; + * * proof unavailable (keypair inaccessible) — pass through + * unsigned so unbound sessions still function (fail-open contract, + * matches `docs/plan/dpop.md`). + */ + +import { buildDpopProof, isDpopNonceChallenge, updateNonceFromResponse } from '$lib/auth/dpop-proof'; + +// eslint-disable-next-line @typescript-eslint/consistent-type-declarations +declare const self: ServiceWorkerGlobalScope; + +const ORIGIN = self.location.origin; + +// Fast-forward the SW lifecycle so open tabs pick up the new version +// on the next navigation without waiting for every existing tab to +// close (default lifecycle stalls activation until then). +self.addEventListener('install', () => { + void self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener('fetch', (event) => { + const req = event.request; + const url = new URL(req.url); + // Same-origin only — never leak a DPoP proof (which carries the + // user's public-key JWK) to a third-party host. + if (url.origin !== ORIGIN) return; + // Only DPoP-protected paths need signing. Static assets, locales, + // vendors are outside the middleware and don't need the crypto tax. + if (!url.pathname.startsWith('/api/')) return; + // Don't double-sign — page-context `apiFetch`, `fetchMe`, `tryRefresh`, + // and `uploadFileWithProgress` already attach a proof themselves. + if (req.headers.has('DPoP')) return; + event.respondWith(signAndFetch(req)); +}); + +async function signAndFetch(req: Request): Promise { + const firstProof = await buildDpopProof(req.method, req.url).catch(() => null); + // No keypair (IndexedDB blocked, SubtleCrypto missing, etc.) — pass + // through unsigned. Unbound sessions still work; bound sessions in + // `required` mode will 401, matching the fail-open contract. + if (!firstProof) return fetch(req); + + // Body-preservation contract: `new Request(existing, ...)` transfers + // ownership of `existing.body` (a `ReadableStream` — read once). To + // keep a retry option open for POST / PUT bodies we tee ahead of the + // first attempt via `req.clone()`. Cheap on GET (no body), one + // stream tee on state-changing calls. + const retryReq = req.clone(); + const first = await fetch(signWith(req, firstProof)); + updateNonceFromResponse(first); + if (!isDpopNonceChallenge(first)) return first; + + // Fresh proof — the current call to `buildDpopProof` picks up the + // nonce we just harvested from `first`'s `DPoP-Nonce` header. + const secondProof = await buildDpopProof(retryReq.method, retryReq.url).catch(() => null); + if (!secondProof) return first; // couldn't sign — surface the challenge + return fetch(signWith(retryReq, secondProof)); +} + +/** + * Build the signed outbound `Request` from an intercepted one. + * + * `mode: 'same-origin'` is load-bearing. Browser-initiated `` + * / `` requests default to `mode: 'no-cors'`, and in no-cors + * mode the browser silently strips any header not on the CORS-safelist + * (`Accept`, `Accept-Language`, `Content-Language`, `Content-Type`) + * BEFORE sending — so `DPoP` would never reach the wire even though + * `Headers.set('DPoP', …)` succeeds in JS. `same-origin` (or `cors`) + * lets custom headers through. Legal for our targets: every path we + * intercept starts with `/api/` on the same origin as the SW itself. + */ +function signWith(req: Request, proof: string): Request { + return new Request(req, { + headers: withDpopHeader(req.headers, proof), + mode: 'same-origin' + }); +} + +function withDpopHeader(existing: Headers, proof: string): Headers { + const h = new Headers(existing); + h.set('DPoP', proof); + return h; +} From 80f2f67db605b70ad2dbc146f48a4c4265f1b35a Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 9 Aug 2026 03:29:25 +0200 Subject: [PATCH 23/51] feat(dpop): server request dpop on all /api/* --- docs/plan/dpop.md | 10 +- src/interfaces/middleware/dpop.rs | 180 +----------------------------- 2 files changed, 7 insertions(+), 183 deletions(-) diff --git a/docs/plan/dpop.md b/docs/plan/dpop.md index 4bfd2e75..eac7fb2b 100644 --- a/docs/plan/dpop.md +++ b/docs/plan/dpop.md @@ -221,7 +221,11 @@ DPoP (RFC 9449) closes this gap by binding the session to a browser-held private - After 2-4 weeks of clean opportunistic-mode telemetry, flip default to `required` in a later release. Pre-existing app-password / legacy sessions with `dpop_jkt IS NULL` still work; they're exempt at the middleware. - **Documentation deliverable**: operator guide entry explaining the flag, the modes, the observability signals, the upgrade path. -## Gate C — Content-serve + streaming allowlist (browser-direct GETs) +## Gate C — Content-serve + streaming allowlist (SUPERSEDED by Service Worker) + +**Status: retired.** A Service Worker at `frontend/src/service-worker.ts` now intercepts every same-origin `/api/*` request the browser initiates and attaches a DPoP proof — including ``, ``, and `EventSource` streams that JS-space fetch can never touch. The middleware allowlist that this section documented has been deleted (`matches_content_path`, `is_content_serve_get`, `looks_like_uuid`, the enforcement branch, plus 9 tests). No exempt paths remain; the DPoP posture is uniform across the whole `/api/*` surface. + +Left in place for the historical rationale: Discovered during the `required`-mode rollout: some SPA endpoints are fetched by the **browser itself**, not by JS via `fetch()`. These paths have no JS in the loop to sign a DPoP proof: @@ -242,9 +246,7 @@ The allowlist exempts ONLY the missing-proof reject. Proofs that ARE sent on the **Security posture (accepted trade-off).** An attacker with a stolen cookie can GET one of these URLs *if and only if* they already know a specific 128-bit UUID. Every listing / discovery endpoint (`/api/folders/{id}/children`, `/api/photos`, `/api/files/by-hash`, `/search`, …) still requires a DPoP proof — those go through `apiFetch`. So a bare stolen cookie gives the attacker "download the exact IDs you already know" — effectively nothing without prior knowledge. Plugin log SSE additionally has admin-only AuthZ at the handler. -**Refactor pending (near-term).** The allowlist currently lives in the DPoP middleware as a slice-pattern matcher over path segments (`matches_content_path` in `src/interfaces/middleware/dpop.rs`). Cleaner: split the router — exempt routes on one sub-router without the `require_dpop_layer`, protected routes with it, merge. Declares exemption next to the route registration rather than centrally in the matcher; deletes the matcher and its 9 tests. Effort: 0.5 day. Behaviour-preserving. - -**Long-term evolution (Option B).** See Deferred to Phase 2 — signed short-lived URL tokens replace the allowlist entirely. +**Refactor considered, superseded.** A split-router variant (exempt routes on one sub-router without `require_dpop_layer`) and a signed short-lived URL token variant (Option B) were both scoped in earlier revisions. Both are moot now that the Service Worker intercepts uniformly. Option B remains in the Deferred section as a fallback if SW registration ever needs to be optional. ## Gate 10 — Observability + admin UX diff --git a/src/interfaces/middleware/dpop.rs b/src/interfaces/middleware/dpop.rs index 1b33c24c..38bc756e 100644 --- a/src/interfaces/middleware/dpop.rs +++ b/src/interfaces/middleware/dpop.rs @@ -49,91 +49,6 @@ use crate::infrastructure::services::dpop_verifier::{ use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::CurrentUser; -/// Content-serving GETs the browser fetches directly from ``, -/// ``, `` — no JS in the loop, so -/// there's no way to attach a DPoP proof (the fetch interceptor never -/// runs). Exempt them from the "bound session missing proof" hard -/// enforcement so the SPA can still show thumbnails, play videos, and -/// download files after DPoP=required flips on. -/// -/// Security posture: an attacker with a stolen cookie can GET these -/// URLs BUT ONLY if they already know a specific 128-bit UUID. -/// Every listing / discovery endpoint (`GET /api/folders//children`, -/// `/search`, `/api/files/by-hash`, `/api/photos`, …) still requires -/// DPoP because it's called via `apiFetch`. So a bare stolen cookie -/// gives the attacker "download the exact IDs you already know" — -/// effectively nothing without prior knowledge. -/// -/// Proofs that ARE sent on these paths (e.g. an image preloader that -/// went through `apiFetch` and blob'd) still get fully verified — -/// this only bypasses the missing-proof reject, not the verifier -/// itself. -/// -/// Long-term Option B (signed short-lived URL tokens) would let us -/// remove this allowlist entirely; tracked as a separate task. -fn is_content_serve_get(method: &axum::http::Method, path: &str) -> bool { - if method != axum::http::Method::GET { - return false; - } - // Note: `path` is nest-stripped by axum (`/api` prefix removed by - // the `/api` nest), so we match against the inner segment. - // `/files/` (download / inline) - // `/files//thumbnail/` (thumbnails) - // `/folders//download` (zip download) - // `/photos//preview` (photo preview, if present) - matches_content_path(path) -} - -fn matches_content_path(path: &str) -> bool { - // Collect segments into a fixed stack buffer, then match all - // allowed shapes as slice patterns — reads left-to-right like - // the paths themselves. Any path deeper than the buffer or not - // matching a listed shape falls through to `false`. No alloc, - // no regex, no leading-slash surprises. - // - // UUID guards (`if looks_like_uuid(id)`) disambiguate - // `/files/` (content) from `/files/by-hash` (a listing - // endpoint that MUST stay behind DPoP for anti-enumeration). - // Plugin slugs aren't UUIDs so the SSE arm just accepts any - // segment there — the endpoint enforces its own admin AuthZ. - let mut buf: [&str; 6] = [""; 6]; - let mut n = 0; - for seg in path.trim_start_matches('/').split('/') { - if n == buf.len() { - return false; - } - buf[n] = seg; - n += 1; - } - match &buf[..n] { - // SSE: EventSource can't attach headers → cookie-only auth - // (RFC 9449 known gap for streaming). Same posture as content- - // serve — attacker still needs the exact target id. - ["admin", "plugins", _id, "logs", "stream"] => true, - // Content-serve GETs — browser fetches directly from - // ``, `img src`, `video src`, ``. - ["files", id] if looks_like_uuid(id) => true, - ["files", id, "thumbnail", _size] if looks_like_uuid(id) => true, - ["folders", id, "download"] if looks_like_uuid(id) => true, - ["photos", id, "preview"] if looks_like_uuid(id) => true, - _ => false, - } -} - -/// Cheap UUID-shape check: 36 chars, hyphens at positions 8, 13, 18, -/// 23, everything else hex. Not a full parse (nothing here needs the -/// bytes) — just enough to distinguish `` from named endpoints -/// like `by-hash`, `upload`, `search`. -fn looks_like_uuid(s: &str) -> bool { - if s.len() != 36 { - return false; - } - s.bytes().enumerate().all(|(i, b)| match i { - 8 | 13 | 18 | 23 => b == b'-', - _ => b.is_ascii_hexdigit(), - }) -} - /// Resolve the request's external `(scheme, host)` — what the client /// sees the URL as, which is what its DPoP proof's `htu` was built /// from. Behind a reverse proxy, the internal request scheme + @@ -221,9 +136,7 @@ pub async fn require_dpop_layer( .to_owned(); let Some(proof) = dpop_header else { match (mode, expected_jkt) { - (DpopMode::Required, Some(_)) - if !is_content_serve_get(request.method(), request.uri().path()) => - { + (DpopMode::Required, Some(_)) => { // Distinct event name: `dpop.proof_missing` means // no header was on the wire at all — no verification // happened. `dpop.verify_failed` is reserved for @@ -480,95 +393,4 @@ mod tests { ); } - // Content-serve allowlist — the paths browsers fetch directly - // from anchors / img / video without JS in the loop. Under - // `required` mode + bound session, missing-proof must NOT reject - // these or the SPA breaks (downloads, thumbnails, video streaming - // all silently 401). - - fn get(path: &str) -> bool { - is_content_serve_get(&axum::http::Method::GET, path) - } - fn post(path: &str) -> bool { - is_content_serve_get(&axum::http::Method::POST, path) - } - - #[test] - fn content_serve_matches_file_download() { - assert!(get("/files/8f8e4390-1234-4a5b-8c9d-abcdef012345")); - } - - #[test] - fn content_serve_matches_thumbnail() { - assert!(get( - "/files/8f8e4390-1234-4a5b-8c9d-abcdef012345/thumbnail/icon" - )); - assert!(get( - "/files/8f8e4390-1234-4a5b-8c9d-abcdef012345/thumbnail/large" - )); - } - - #[test] - fn content_serve_matches_folder_zip() { - assert!(get( - "/folders/8f8e4390-1234-4a5b-8c9d-abcdef012345/download" - )); - } - - #[test] - fn content_serve_matches_photo_preview() { - assert!(get("/photos/8f8e4390-1234-4a5b-8c9d-abcdef012345/preview")); - } - - #[test] - fn content_serve_rejects_non_get() { - // Attacker with stolen cookie can't mutate — POST/DELETE - // to the SAME url still requires DPoP. - assert!(!post("/files/8f8e4390-1234-4a5b-8c9d-abcdef012345")); - assert!(!is_content_serve_get( - &axum::http::Method::DELETE, - "/files/8f8e4390-1234-4a5b-8c9d-abcdef012345" - )); - } - - #[test] - fn content_serve_rejects_listing_endpoints() { - // Discovery endpoints (children, by-hash, search) MUST NOT be - // allowlisted — that's the whole security argument. Attacker - // needs to already know the UUID; can't enumerate. - assert!(!get( - "/folders/8f8e4390-1234-4a5b-8c9d-abcdef012345/children" - )); - assert!(!get("/files/by-hash")); - assert!(!get("/search")); - assert!(!get("/auth/me")); - } - - #[test] - fn content_serve_rejects_root_or_id_only() { - assert!(!get("/")); - assert!(!get("/files")); - assert!(!get("/folders")); - } - - #[test] - fn content_serve_matches_plugin_log_sse_stream() { - // Server-Sent Events endpoint. Browser uses EventSource, - // which can't attach custom headers — cookie-only auth is - // the RFC 9449 known gap for streaming. Allowlisted. - assert!(get("/admin/plugins/com.example.hello/logs/stream")); - assert!(get("/admin/plugins/some-slug/logs/stream")); - } - - #[test] - fn content_serve_rejects_other_admin_plugin_endpoints() { - // Only the SSE stream is allowlisted. Every other plugin - // endpoint (list, install, uninstall, config) stays behind - // DPoP for anti-enumeration and mutation protection. - assert!(!get("/admin/plugins")); - assert!(!get("/admin/plugins/some-slug")); - assert!(!get("/admin/plugins/some-slug/logs")); - assert!(!get("/admin/plugins/some-slug/config")); - assert!(!post("/admin/plugins/some-slug/logs/stream")); - } } From 1b9d812175d597dbc5762a94827cc3ce51815ace Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 9 Aug 2026 03:05:14 +0200 Subject: [PATCH 24/51] feat(telemetry): add /metrics prommetheus exporter --- Cargo.lock | 162 +++++++++++++++++++++++++++++- Cargo.toml | 9 ++ docs/config/env.md | 1 + example.env | 12 +++ src/common/config.rs | 27 +++++ src/interfaces/metrics.rs | 97 ++++++++++++++++++ src/interfaces/middleware/dpop.rs | 20 ++++ src/interfaces/mod.rs | 1 + src/main.rs | 13 +++ 9 files changed, 338 insertions(+), 4 deletions(-) create mode 100644 src/interfaces/metrics.rs diff --git a/Cargo.lock b/Cargo.lock index b3d44d27..2ee0cc0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -160,7 +160,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -171,7 +171,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2328,6 +2328,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "evmap" +version = "11.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8874945f036109c72242964c1174cf99434e30cfa45bf45fedc983f50046f8" +dependencies = [ + "hashbag", + "left-right", + "smallvec", +] + [[package]] name = "extism" version = "1.30.0" @@ -2743,6 +2754,21 @@ dependencies = [ "serde_json", ] +[[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -2918,6 +2944,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbag" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7040a10f52cba493ddb09926e15d10a9d8a28043708a405931fe4c6f19fac064" + [[package]] name = "hashbrown" version = "0.12.3" @@ -3441,7 +3473,7 @@ checksum = "af1955a75fa080c677d3972822ec4bad316169ab1cfc6c257a942c2265dbe5fe" dependencies = [ "bitmaps", "rand_core 0.6.4", - "rand_xoshiro", + "rand_xoshiro 0.6.0", "sized-chunks", "typenum", "version_check", @@ -3719,6 +3751,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "left-right" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bc015ded5d9b3054dbbdb63332cdd6ee42352ccef19e911e25117490e2f48ee" +dependencies = [ + "crossbeam-utils", + "loom", + "slab", +] + [[package]] name = "lettre" version = "0.11.22" @@ -3855,6 +3898,19 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + [[package]] name = "lopdf" version = "0.42.0" @@ -4019,6 +4075,48 @@ dependencies = [ "libc", ] +[[package]] +name = "metrics" +version = "0.24.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89550ee9f79e88fef3119de263694973a8adb26c21d75322164fb8c493039fe2" +dependencies = [ + "portable-atomic", + "rapidhash", +] + +[[package]] +name = "metrics-exporter-prometheus" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1db0d8f1fc9e62caebd0319e11eaec5822b0186c171568f0480b46a0137f9108" +dependencies = [ + "base64 0.22.1", + "evmap", + "indexmap 2.14.0", + "metrics", + "metrics-util", + "quanta", + "thiserror 2.0.18", +] + +[[package]] +name = "metrics-util" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96f8722f8562635f92f8ed992f26df0532266eb03d5202607c20c0d7e9745e13" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", + "hashbrown 0.16.1", + "metrics", + "quanta", + "rand 0.9.4", + "rand_xoshiro 0.7.0", + "rapidhash", + "sketches-ddsketch 0.3.1", +] + [[package]] name = "mimalloc" version = "0.1.52" @@ -4481,6 +4579,8 @@ dependencies = [ "lettre", "lru", "md-5 0.11.0", + "metrics", + "metrics-exporter-prometheus", "mimalloc", "mime_guess", "mockall", @@ -4987,6 +5087,21 @@ version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi 0.11.1+wasi-snapshot-preview1", + "web-sys", + "winapi", +] + [[package]] name = "quick-error" version = "2.0.1" @@ -5220,12 +5335,39 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_xoshiro" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "rangemap" version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" +[[package]] +name = "rapidhash" +version = "4.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e" +dependencies = [ + "rustversion", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + [[package]] name = "rawpointer" version = "0.2.1" @@ -5657,6 +5799,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -5969,6 +6117,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "sketches-ddsketch" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" + [[package]] name = "sketches-ddsketch" version = "0.4.0" @@ -6404,7 +6558,7 @@ dependencies = [ "rustc-hash", "serde", "serde_json", - "sketches-ddsketch", + "sketches-ddsketch 0.4.0", "smallvec", "tantivy-bitpacker", "tantivy-columnar", diff --git a/Cargo.toml b/Cargo.toml index 37d81fe7..346ba04a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -134,6 +134,15 @@ toml = { version = "1.1.2", optional = true } file-rotate = { version = "0.7.6", optional = true } ort = { version = "2.0.0-rc.12", default-features = false, features = ["load-dynamic", "ndarray", "tracing", "api-24"], optional = true } ndarray = { version = "0.17.2", optional = true } +# Prometheus /metrics exporter (opt-in via OXICLOUD_METRICS_LISTEN). +# `metrics` is the abstract counter API — `counter!(name, "k" => v).increment(1)` +# — and is a no-op when no recorder is installed (unset env var). The +# `metrics-exporter-prometheus` crate registers a recorder and renders the +# text-format /metrics scrape. Both re-export ordered voted versions that +# agree on their `metrics-util` transitive dep — pin as a pair when +# bumping either side. +metrics = "0.24" +metrics-exporter-prometheus = { version = "0.18", default-features = false } [features] default = [] diff --git a/docs/config/env.md b/docs/config/env.md index d8bb5d14..ffc09e5f 100644 --- a/docs/config/env.md +++ b/docs/config/env.md @@ -17,6 +17,7 @@ Most runtime variables use the `OXICLOUD_` prefix. A few build-time or allocator | `OXICLOUD_CHUNK_MAX_BYTES` | `104857600` | Maximum size of a single chunked-upload PUT in bytes (100 MB). Per-chunk cap, independent of `OXICLOUD_MAX_UPLOAD_SIZE` (whole-file cap). See [Storage Fine Tuning](./storage-fine-tuning.md). | | `OXICLOUD_CHUNK_DIR` | `{STORAGE_PATH}/.uploads` | Root directory for chunked-upload sessions (REST + NextCloud). Direct (non-chunked) uploads stream straight into the blob store and need no spool directory. Placement guidance: see [Storage Fine Tuning](./storage-fine-tuning.md). | | `OXICLOUD_REUSE_PORT` | `false` | Enable `SO_REUSEPORT` so multiple processes can share the same port. **Disabled by default** — a second accidental instance will fail with "address already in use". Enable only for deliberate multi-worker setups (process supervisor, rolling restart). Not supported on Windows. | +| `OXICLOUD_METRICS_LISTEN` | (unset) | Prometheus `/metrics` listener address (e.g. `127.0.0.1:9090`, IPv6 allowed as `[::1]:9090`). **Unset = disabled**: no `/metrics` endpoint is bound and no metrics recorder is installed (zero runtime cost). When set, a separate HTTP listener on this address serves the text-format scrape. **Deliberately NOT merged into the main API** — no auth, CSRF, or DPoP layer in front. Bind to loopback or a private interface unless you intend to expose metrics publicly. Starter counters: `oxicloud_dpop_verify_failed_total{reason}`, `oxicloud_dpop_proof_missing_total`, `oxicloud_dpop_header_missing_on_bound_session_total`, `oxicloud_dpop_replay_detected_total`, `oxicloud_dpop_nonce_challenges_issued_total`. | ## Database diff --git a/example.env b/example.env index e7c86ee4..13dec374 100644 --- a/example.env +++ b/example.env @@ -42,6 +42,18 @@ OXICLOUD_SERVER_HOST=127.0.0.1 # Example: https://cloud.example.com #OXICLOUD_BASE_URL=https://cloud.example.com +# Prometheus /metrics listener — OFF by default. +# When unset (or empty), no /metrics endpoint is exposed and no +# metrics recorder is installed (zero runtime cost). +# When set, a SEPARATE HTTP listener on this address serves the +# text-format scrape at /metrics. It is NOT merged into the main +# API — no auth, CSRF, or DPoP layer in front. Bind it to loopback +# or a private interface; make it publicly reachable only if you +# intend to expose metrics publicly. +# Format: host:port (IPv6 allowed, e.g. [::1]:9090). +# Recommended: 127.0.0.1:9090 with node_exporter-style scrapers. +#OXICLOUD_METRICS_LISTEN=127.0.0.1:9090 + # ── Upload size caps ────────────────────────────────────────────────── # See docs/config/storage-fine-tuning.md for sizing guidance. diff --git a/src/common/config.rs b/src/common/config.rs index a9b62229..fc3b54bb 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -2533,6 +2533,16 @@ pub struct AppConfig { pub server_port: u16, /// Server host pub server_host: String, + /// Prometheus `/metrics` listener address, or `None` to disable. + /// + /// Env: `OXICLOUD_METRICS_LISTEN` (e.g. `127.0.0.1:9090`). + /// Unset / empty = no metrics recorder is installed and no + /// `/metrics` endpoint is bound (default). When set, a separate + /// axum listener on this address exposes the text-format scrape + /// — deliberately NOT merged into the main API so operators can + /// bind to loopback / a private interface without exposing + /// metrics publicly. + pub metrics_listen: Option, /// Cache configuration pub cache: CacheConfig, /// Timeout configuration @@ -2660,6 +2670,7 @@ impl Default for AppConfig { search_cache: SearchCacheConfig::default(), plugins: PluginConfig::default(), faces: FacesConfig::default(), + metrics_listen: None, } } } @@ -2691,6 +2702,22 @@ impl AppConfig { config.server_host = server_host; } + // Prometheus /metrics listener — opt-in, off by default. Empty + // string treated the same as unset (a common bare-word `=` shape + // in .env files). Parse failure is a fatal-shaped warning so + // operators don't silently ship without metrics they expected. + if let Ok(raw) = env::var("OXICLOUD_METRICS_LISTEN") + && !raw.trim().is_empty() + { + match raw.parse::() { + Ok(addr) => config.metrics_listen = Some(addr), + Err(err) => tracing::warn!( + "OXICLOUD_METRICS_LISTEN={raw:?} is not a valid socket address ({err}) \ + — metrics endpoint will NOT be exposed" + ), + } + } + // Database configuration if let Ok(connection_string) = env::var("OXICLOUD_DB_CONNECTION_STRING") { config.database.connection_string = connection_string; diff --git a/src/interfaces/metrics.rs b/src/interfaces/metrics.rs new file mode 100644 index 00000000..2ec488e3 --- /dev/null +++ b/src/interfaces/metrics.rs @@ -0,0 +1,97 @@ +//! Prometheus `/metrics` exporter — opt-in, isolated listener. +//! +//! Enabled iff `OXICLOUD_METRICS_LISTEN` is set (see +//! [`crate::common::config::AppConfig::metrics_listen`]). When unset, +//! no recorder is installed and every `metrics::counter!(…)` call +//! across the codebase compiles to a no-op — no runtime cost, no +//! endpoint bound. When set, this module: +//! +//! 1. Installs the process-global Prometheus recorder (once — panics +//! if called twice, so [`spawn`] MUST be a single-call site). +//! 2. Binds a fresh `axum` `Router` on the configured address exposing +//! only `GET /metrics`. Deliberately **not merged** into the main +//! API router — operators bind to loopback / a private interface +//! (typical: `127.0.0.1:9090` for a node_exporter-adjacent scrape) +//! without any auth, CSRF, or DPoP layer in front. Public exposure +//! is an operator choice via the bind address, not an app default. +//! 3. Spawns the listener on a detached tokio task — the metrics +//! endpoint's lifetime tracks the runtime, and a listener error +//! logs but doesn't take the main server down. +//! +//! Counter naming follows Prometheus conventions: +//! `oxicloud___total{label=…}`. Emission is +//! **duplicated** with existing audit `tracing::info!(target: "audit", …)` +//! lines — logs stay authoritative for incident forensics; counters +//! are for rate / rollup dashboards. Never remove one when adding the +//! other. +//! +//! Starter counter surface (extend as needed): +//! * `oxicloud_dpop_verify_failed_total{reason}` +//! * `oxicloud_dpop_proof_missing_total` +//! * `oxicloud_dpop_header_missing_on_bound_session_total` +//! * `oxicloud_dpop_replay_detected_total` +//! * `oxicloud_dpop_nonce_challenges_issued_total` + +use axum::{Router, extract::State, http::header, response::IntoResponse, routing::get}; +use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle}; +use std::net::SocketAddr; + +/// Error type returned by [`spawn`]. Uses the same `Box` +/// shape `main` already threads for setup failures — one less crate +/// dep (`anyhow`) and no coupling to a specific error framework. +pub type BoxError = Box; + +/// Install the Prometheus recorder and spawn the `/metrics` listener. +/// +/// Idempotent-unsafe: MUST be called at most once per process (the +/// recorder is a process-global singleton). Caller (main.rs) checks +/// `config.metrics_listen.is_some()` — no runtime guard here. +/// +/// Returns immediately after `bind` succeeds; the listener runs on a +/// detached tokio task. A bind failure returns the error so main can +/// decide whether to abort (recommended) or continue without metrics. +pub async fn spawn(bind: SocketAddr) -> Result<(), BoxError> { + let handle: PrometheusHandle = + PrometheusBuilder::new() + .install_recorder() + .map_err(|err| -> BoxError { + format!("failed to install Prometheus recorder: {err}").into() + })?; + + let app = Router::new() + .route("/metrics", get(scrape)) + .with_state(handle); + + let listener = tokio::net::TcpListener::bind(bind) + .await + .map_err(|err| -> BoxError { + format!("failed to bind metrics listener on {bind}: {err}").into() + })?; + let actual = listener.local_addr()?; + tracing::info!( + target: "oxicloud::metrics", + "📊 Prometheus /metrics listening on http://{actual}/metrics", + ); + + tokio::spawn(async move { + if let Err(err) = axum::serve(listener, app).await { + tracing::error!( + target: "oxicloud::metrics", + "metrics listener terminated with error: {err}", + ); + } + }); + Ok(()) +} + +/// Render the current Prometheus text-format snapshot. Content-type +/// per spec: `text/plain; version=0.0.4`; scrapers parse strictly. +async fn scrape(State(handle): State) -> impl IntoResponse { + ( + [( + header::CONTENT_TYPE, + "text/plain; version=0.0.4; charset=utf-8", + )], + handle.render(), + ) +} diff --git a/src/interfaces/middleware/dpop.rs b/src/interfaces/middleware/dpop.rs index 38bc756e..2308d8c3 100644 --- a/src/interfaces/middleware/dpop.rs +++ b/src/interfaces/middleware/dpop.rs @@ -154,6 +154,7 @@ pub async fn require_dpop_layer( user_agent = %req_user_agent, "👮🏻‍♂️ DPoP required: bound session request has no proof", ); + metrics::counter!("oxicloud_dpop_proof_missing_total").increment(1); return nonce_challenge_response(&nonce_service); } (DpopMode::Opportunistic, Some(_)) => { @@ -171,6 +172,8 @@ pub async fn require_dpop_layer( user_agent = %req_user_agent, "⚠️ DPoP: bound session sent request without a proof", ); + metrics::counter!("oxicloud_dpop_header_missing_on_bound_session_total") + .increment(1); } _ => { /* unbound session or off mode — nothing to do */ } } @@ -221,6 +224,11 @@ pub async fn require_dpop_layer( htu = %htu, "👮🏻‍♂️ DPoP nonce stale — issuing challenge", ); + metrics::counter!( + "oxicloud_dpop_verify_failed_total", + "reason" => "nonce_stale", + ) + .increment(1); return nonce_challenge_response(&nonce_service); } None => { @@ -248,6 +256,7 @@ pub async fn require_dpop_layer( jti = %verified.jti, "👮🏻‍♂️ DPoP proof replayed — same (nonce, jti) seen twice", ); + metrics::counter!("oxicloud_dpop_replay_detected_total").increment(1); return dpop_verification_failed_response( DpopVerifyError::SignatureInvalid, // shape-only; audit line carries truth &nonce_service, @@ -266,6 +275,11 @@ pub async fn require_dpop_layer( htu = %htu, "👮🏻‍♂️ DPoP proof rejected", ); + metrics::counter!( + "oxicloud_dpop_verify_failed_total", + "reason" => err.reason(), + ) + .increment(1); dpop_verification_failed_response(err, &nonce_service) } } @@ -290,9 +304,15 @@ fn stamp_current_nonce( /// WWW-Authenticate + DPoP-Nonce carrying a fresh nonce. The SPA /// fetch interceptor (Gate 4) auto-retries once with the new nonce /// so users don't experience a visible failure. +/// +/// Central counter emission (`oxicloud_dpop_nonce_challenges_issued_total`) +/// lives here rather than at each callsite — every challenge goes +/// through this helper by construction, so one increment covers all +/// three current paths (proof-missing, nonce-missing, nonce-stale). fn nonce_challenge_response( nonce_service: &crate::infrastructure::services::dpop_nonce_service::DpopNonceService, ) -> Response { + metrics::counter!("oxicloud_dpop_nonce_challenges_issued_total").increment(1); let mut resp = AppError::new( StatusCode::UNAUTHORIZED, "DPoP nonce required", diff --git a/src/interfaces/mod.rs b/src/interfaces/mod.rs index f9c9f41f..aded6acd 100644 --- a/src/interfaces/mod.rs +++ b/src/interfaces/mod.rs @@ -1,5 +1,6 @@ pub mod api; pub mod errors; +pub mod metrics; pub mod middleware; pub mod nextcloud; pub mod range_requests; diff --git a/src/main.rs b/src/main.rs index 6fd1af72..865d9e59 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1392,6 +1392,19 @@ async fn run() -> Result<(), Box> { tracing::info!("Starting OxiCloud server on http://{}", addr); + // Opt-in Prometheus `/metrics` exporter on a separate listener. + // Installs the recorder BEFORE the main listener starts serving so + // the first request's counter increments are captured (recorder + // install is racy vs first emit — order matters). + if let Some(metrics_addr) = config.metrics_listen { + if let Err(err) = oxicloud::interfaces::metrics::spawn(metrics_addr).await { + // Fail loudly: operators asked for metrics; not surfacing + // this would hide a misconfigured scrape endpoint. + tracing::error!("Prometheus /metrics setup failed: {err}"); + return Err(err); + } + } + let socket = make_socket(&addr, reuse_port)?; let listener = tokio::net::TcpListener::from_std(socket.into())?; From bee856fbd04afe251ced27fb97f00f91dfcf6a7b Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 9 Aug 2026 03:39:19 +0200 Subject: [PATCH 25/51] feat(session): handle sessions for admin --- frontend/src/lib/api/endpoints/admin.ts | 34 ++++ frontend/src/lib/api/types.ts | 28 +++ frontend/src/lib/components/AppShell.svelte | 6 + frontend/src/lib/utils/userAgent.test.ts | 80 ++++++++ frontend/src/lib/utils/userAgent.ts | 55 +++++ .../src/routes/admin/[[tab]]/+page.svelte | 189 ++++++++++++++++++ src/application/dtos/mod.rs | 1 + src/application/dtos/session_dto.rs | 164 +++++++++++++++ src/application/dtos/settings_dto.rs | 13 ++ src/application/ports/auth_ports.rs | 18 ++ .../services/auth_application_service.rs | 161 ++++++++++++--- src/domain/repositories/session_repository.rs | 17 ++ .../repositories/pg/session_pg_repository.rs | 84 ++++++++ src/interfaces/api/handlers/admin_handler.rs | 108 +++++++++- src/interfaces/api/handlers/auth_handler.rs | 44 +++- .../api/handlers/magic_link_handler.rs | 15 ++ .../api/handlers/opaque_auth_handler.rs | 15 +- 17 files changed, 997 insertions(+), 35 deletions(-) create mode 100644 frontend/src/lib/utils/userAgent.test.ts create mode 100644 frontend/src/lib/utils/userAgent.ts create mode 100644 src/application/dtos/session_dto.rs diff --git a/frontend/src/lib/api/endpoints/admin.ts b/frontend/src/lib/api/endpoints/admin.ts index 23834d19..2cf9ee48 100644 --- a/frontend/src/lib/api/endpoints/admin.ts +++ b/frontend/src/lib/api/endpoints/admin.ts @@ -6,6 +6,7 @@ import { apiFetch, apiJson } from '$lib/api/client'; import { getCsrfHeaders } from '$lib/api/csrf'; import type { + AdminSessionsPage, AdminUsersPage, Drive, DriveMember, @@ -241,6 +242,39 @@ export async function deleteDriveAdmin(driveId: string): Promise { } } +// ── Sessions (admin panel) ────────────────────────────────────────────── + +/** Options for {@link listAdminSessions}. */ +export interface ListSessionsOpts { + /** Narrow to one user's sessions; omit for cross-user listing. */ + userId?: string; + /** Include revoked / expired rows. Default `false` (active-only UX). */ + includeRevoked?: boolean; + /** Page size — server caps at 500. */ + limit?: number; + /** Pagination offset. */ + offset?: number; +} + +/** Global sessions listing — `GET /api/admin/sessions`. */ +export function listAdminSessions(opts: ListSessionsOpts = {}): Promise { + const params = new URLSearchParams(); + if (opts.userId) params.set('user_id', opts.userId); + if (opts.includeRevoked) params.set('include_revoked', 'true'); + if (opts.limit !== undefined) params.set('limit', String(opts.limit)); + if (opts.offset !== undefined) params.set('offset', String(opts.offset)); + const qs = params.toString(); + return apiJson(`/api/admin/sessions${qs ? '?' + qs : ''}`, { + credentials: 'same-origin' + }); +} + +/** Revoke a session — `DELETE /api/admin/sessions/{id}`. Sets + * `revoked=true`; the row stays in the DB for audit visibility. */ +export function revokeAdminSession(sessionId: string): Promise { + return mutate(`/api/admin/sessions/${encodeURIComponent(sessionId)}`, 'DELETE'); +} + // ── Users ─────────────────────────────────────────────────────────────── /** List the compact rows rendered by the management table; full account diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index f8cf8aed..51ee665e 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -734,3 +734,31 @@ export interface Finding { detail: Record; created_at: string; } + +/** + * Admin sessions-panel row shape. Backend: `SessionSummaryDto` in + * `src/application/dtos/session_dto.rs`. Deliberately narrower than + * the DB row — the refresh token is never serialised, and the full + * DPoP thumbprint is truncated to an 8-char prefix so admins viewing + * other users' sessions can't exfiltrate the full binding fingerprint. + */ +export interface SessionSummary { + id: string; + user_id: string; + created_at: string; + expires_at: string; + ip_address: string | null; + user_agent: string | null; + is_bound: boolean; + dpop_jkt_prefix: string | null; + is_revoked: boolean; + is_active: boolean; + oidc_sid: string | null; +} + +/** Wire response of `GET /api/admin/sessions`. */ +export interface AdminSessionsPage { + sessions: SessionSummary[]; + limit: number; + offset: number; +} diff --git a/frontend/src/lib/components/AppShell.svelte b/frontend/src/lib/components/AppShell.svelte index b1d4f788..da6107d0 100644 --- a/frontend/src/lib/components/AppShell.svelte +++ b/frontend/src/lib/components/AppShell.svelte @@ -86,6 +86,12 @@ icon: 'users', section: 'admin-users' }, + { + href: '/admin/sessions', + label: t('admin.sessions', 'Sessions'), + icon: 'key', + section: 'admin-sessions' + }, { href: '/admin/drives', label: t('admin.drives', 'Drives'), diff --git a/frontend/src/lib/utils/userAgent.test.ts b/frontend/src/lib/utils/userAgent.test.ts new file mode 100644 index 00000000..63b89d4a --- /dev/null +++ b/frontend/src/lib/utils/userAgent.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest'; +import { shortUserAgent } from './userAgent'; + +describe('shortUserAgent', () => { + it('placeholder for missing input', () => { + expect(shortUserAgent(null)).toBe('—'); + expect(shortUserAgent(undefined)).toBe('—'); + expect(shortUserAgent('')).toBe('—'); + }); + + it('device-auth marker passes through unchanged', () => { + expect(shortUserAgent('device:my-tv-42')).toBe('device:my-tv-42'); + }); + + it.each([ + [ + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36', + 'Chrome on Mac' + ], + [ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36', + 'Chrome on Windows' + ], + [ + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36', + 'Chrome on Linux' + ], + [ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0', + 'Firefox on Windows' + ], + [ + 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:126.0) Gecko/20100101 Firefox/126.0', + 'Firefox on Linux' + ], + [ + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15', + 'Safari on Mac' + ], + [ + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1', + 'Safari on iOS' + ], + [ + // iPad on iPadOS 13+ ships a UA with "Macintosh" — must NOT + // mis-detect as Mac. Guarded by iOS-first ordering. + 'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1', + 'Safari on iOS' + ], + [ + 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Mobile Safari/537.36', + 'Chrome on Android' + ], + [ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 Edg/125.0.2535.51', + 'Edge on Windows' + ], + [ + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 OPR/110.0.0.0', + 'Opera on Linux' + ], + ['curl/8.7.1', 'curl'], + ['Wget/1.21.4', 'wget'], + ['Mozilla/5.0 (Nextcloud desktop client 3.14.2 stable-x86_64)', 'Nextcloud client'], + ['node-fetch/1.0 (+https://github.com/bitinn/node-fetch)', 'Node'] + ])('%s → %s', (ua, expected) => { + expect(shortUserAgent(ua)).toBe(expected); + }); + + it('truncates unknown UA shapes past 40 chars', () => { + const long = 'SomeWeirdCrawler/1.0 with a very long description of its capabilities'; + const result = shortUserAgent(long); + expect(result.endsWith('…')).toBe(true); + expect(result.length).toBeLessThanOrEqual(41); + }); + + it('returns short unknown UA verbatim', () => { + expect(shortUserAgent('MyBot/1.0')).toBe('MyBot/1.0'); + }); +}); diff --git a/frontend/src/lib/utils/userAgent.ts b/frontend/src/lib/utils/userAgent.ts new file mode 100644 index 00000000..1a03e7f8 --- /dev/null +++ b/frontend/src/lib/utils/userAgent.ts @@ -0,0 +1,55 @@ +/** + * Parse a raw HTTP `User-Agent` string into a compact human label like + * "Chrome on Mac" for admin/session UIs. Not a general-purpose UA + * parser — pragmatic regex-based buckets covering the browsers + + * operating systems that make up ~99% of real-world traffic, plus the + * OxiCloud-specific device-auth prefix. + * + * Order of detection matters: + * * Edge / Opera / Firefox before Chrome (they all include `Chrome/…`) + * * Chrome before Safari (Chrome includes `Safari/…`) + * * Version check guards Safari against matching a WebKit-based crawler + * + * `null` / `undefined` / empty → `"—"` so the admin table renders a + * consistent placeholder without every callsite writing `?? '—'`. + */ +export function shortUserAgent(ua: string | null | undefined): string { + if (!ua) return '—'; + + // Device-authorization grant sessions carry a bespoke marker + // (`device:`) instead of a browser UA. Pass through. + if (ua.startsWith('device:')) return ua; + + // Browser detection — order matters. + let browser: string | null = null; + if (/\bEdg[eA]?\//.test(ua)) browser = 'Edge'; + else if (/\bOPR\/|Opera\//.test(ua)) browser = 'Opera'; + else if (/\bFirefox\/|FxiOS\//.test(ua)) browser = 'Firefox'; + else if (/\bChrome\//.test(ua)) browser = 'Chrome'; + else if (/\bSafari\//.test(ua) && /\bVersion\//.test(ua)) browser = 'Safari'; + else if (/\bcurl\//.test(ua)) browser = 'curl'; + else if (/\bwget/i.test(ua)) browser = 'wget'; + else if (/\bNextcloud\b/i.test(ua)) browser = 'Nextcloud client'; + else if (/\bnode\b/i.test(ua)) browser = 'Node'; + + // OS detection — iOS/iPad before Mac (iPad UAs include "Macintosh" on + // modern iPadOS "desktop mode"; without the iPad check first they'd + // be miscategorised as Mac). + let os: string | null = null; + if (/Windows/i.test(ua)) os = 'Windows'; + else if (/iPhone|iPad|iPod/i.test(ua)) os = 'iOS'; + else if (/Android/i.test(ua)) os = 'Android'; + else if (/Mac OS X|Macintosh/i.test(ua)) os = 'Mac'; + else if (/CrOS/i.test(ua)) os = 'ChromeOS'; + else if (/Linux/i.test(ua)) os = 'Linux'; + else if (/FreeBSD|OpenBSD|NetBSD/i.test(ua)) os = 'BSD'; + + if (browser && os) return `${browser} on ${os}`; + if (browser) return browser; + if (os) return os; + + // Unknown shape — truncate the raw string so a huge UA doesn't + // blow up the table column width. Full string still available in + // the row's `title=` tooltip. + return ua.length > 40 ? ua.slice(0, 40) + '…' : ua; +} diff --git a/frontend/src/routes/admin/[[tab]]/+page.svelte b/frontend/src/routes/admin/[[tab]]/+page.svelte index 837c631f..081979c6 100644 --- a/frontend/src/routes/admin/[[tab]]/+page.svelte +++ b/frontend/src/routes/admin/[[tab]]/+page.svelte @@ -18,6 +18,8 @@ installPlugin, listPlugins, listUsers, + listAdminSessions, + revokeAdminSession, getUserAdmin, migrationAction, reextractAudioMetadata, @@ -73,8 +75,10 @@ DriveMember, DrivePolicies, DrivePoliciesPartial, + SessionSummary, User } from '$lib/api/types'; + import { shortUserAgent } from '$lib/utils/userAgent'; import { triggerJob } from '$lib/api/endpoints/adminJobs'; import { serverStatus } from '$lib/stores/serverStatus.svelte'; import AdminJobsPanel from '$lib/components/AdminJobsPanel.svelte'; @@ -177,6 +181,7 @@ type Tab = | 'dashboard' | 'users' + | 'sessions' | 'drives' | 'mounts' | 'plugins' @@ -188,6 +193,7 @@ const VALID_TABS: readonly Tab[] = [ 'dashboard', 'users', + 'sessions', 'drives', 'mounts', 'plugins', @@ -221,6 +227,8 @@ return t('admin.dashboard', 'Dashboard'); case 'users': return t('admin.users', 'Users'); + case 'sessions': + return t('admin.sessions', 'Sessions'); case 'drives': return t('admin.drives', 'Drives'); case 'mounts': @@ -842,6 +850,56 @@ let resetError = $state(null); let resetting = $state(false); + // Sessions (admin panel — see task #52 / docs/plan/dpop.md Gate 10). + // Global cross-user listing by default; user-filter dropdown narrows. + // Active-only by default (hides revoked + expired); checkbox opts into + // showing everything for forensics. Revoke action mutates in place — + // the row is refetched to update `is_revoked` badge. + let sessions = $state([]); + let sessionsError = $state(null); + let sessionsLoading = $state(false); + let sessionsFilterUserId = $state(''); + let sessionsIncludeRevoked = $state(false); + let sessionRevokingId = $state(null); + + async function loadSessions() { + sessionsLoading = true; + sessionsError = null; + try { + const page = await listAdminSessions({ + userId: sessionsFilterUserId || undefined, + includeRevoked: sessionsIncludeRevoked, + limit: PAGE_SIZE + }); + sessions = page.sessions; + } catch (e) { + sessionsError = errorMessage(e); + } finally { + sessionsLoading = false; + } + } + + async function onRevokeSession(id: string) { + if ( + !confirm( + t( + 'admin.sessions.revoke_confirm', + 'Revoke this session? The next request from that browser will 401.' + ) + ) + ) + return; + sessionRevokingId = id; + try { + await revokeAdminSession(id); + await loadSessions(); + } catch (e) { + sessionsError = errorMessage(e); + } finally { + sessionRevokingId = null; + } + } + // Plugins let plugins = $state([]); let pluginsAvailable = $state(true); @@ -1581,6 +1639,7 @@ let loaded = $state>({ dashboard: false, users: false, + sessions: false, drives: false, mounts: false, plugins: false, @@ -1595,6 +1654,7 @@ loaded[tab] = true; if (tab === 'dashboard') void loadDashboard(); else if (tab === 'users') void loadUsers(); + else if (tab === 'sessions') void loadSessions(); else if (tab === 'drives') void loadDrivesTab(); else if (tab === 'mounts') void loadMounts(); else if (tab === 'plugins') void loadPlugins(); @@ -2898,6 +2958,135 @@ > {/if} + {:else if tab === 'sessions'} +
+

{t('admin.sessions.title', 'Sessions')}

+

+ {t( + 'admin.sessions.help', + 'Active sign-in sessions across all users. A locked icon means the session is bound to a browser keypair (DPoP) — a stolen cookie alone cannot use it. Revoke to force the browser to re-authenticate on its next request.' + )} +

+ +
+ + + +
+ + {#if sessionsError} +
+ {sessionsError} +
+ {/if} + +
+ + + + + + + + + + + + + + + {#each sessions as s (s.id)} + + + + + + + + + + + {/each} + {#if sessions.length === 0 && !sessionsLoading} + + + + {/if} + +
{t('admin.sessions.col_user', 'User')}{t('admin.sessions.col_created', 'Created')}{t('admin.sessions.col_expires', 'Expires')}{t('admin.sessions.col_ip', 'IP')}{t('admin.sessions.col_user_agent', 'User agent')}{t('admin.sessions.col_bound', 'Bound')}{t('admin.sessions.col_status', 'Status')}
{s.user_id.slice(0, 8)}…{new Date(s.created_at).toLocaleString()}{new Date(s.expires_at).toLocaleString()}{s.ip_address ?? '—'} + {shortUserAgent(s.user_agent)} + + {#if s.is_bound} + + 🔒 {s.dpop_jkt_prefix ?? ''} + + {:else} + {t('admin.sessions.unbound', 'unbound')} + {/if} + + {#if s.is_revoked} + + {t('admin.sessions.revoked', 'revoked')} + + {:else if !s.is_active} + {t('admin.sessions.expired', 'expired')} + {:else} + + {t('admin.sessions.active', 'active')} + + {/if} + + {#if !s.is_revoked} + + {/if} +
+ {t('admin.sessions.empty', 'No sessions match the current filter.')} +
+
+
{:else if tab === 'mounts'}

{t('admin.mounts.title', 'External File Mounts')}

diff --git a/src/application/dtos/mod.rs b/src/application/dtos/mod.rs index 58a76241..ab8ae8be 100644 --- a/src/application/dtos/mod.rs +++ b/src/application/dtos/mod.rs @@ -20,6 +20,7 @@ pub mod playlist_dto; pub mod plugin_dto; pub mod recent_dto; pub mod search_dto; +pub mod session_dto; pub mod settings_dto; pub mod share_dto; pub mod trash_dto; diff --git a/src/application/dtos/session_dto.rs b/src/application/dtos/session_dto.rs new file mode 100644 index 00000000..0554f6a1 --- /dev/null +++ b/src/application/dtos/session_dto.rs @@ -0,0 +1,164 @@ +//! DTOs for the admin sessions panel. +//! +//! [`SessionSummaryDto`] is the wire shape returned by +//! `GET /api/admin/sessions`. It's deliberately narrower than the +//! `Session` domain entity — the `refresh_token` and any OIDC +//! ID-token payload are **never** serialized; the raw DPoP thumbprint +//! is truncated to an 8-char prefix so an admin viewing another +//! user's sessions cannot exfiltrate the full binding fingerprint. +//! +//! Enrichment (username/email lookup for each `user_id`) is +//! intentionally deferred to the SPA — it already caches the admin +//! user list, and doing the JOIN server-side would either force a +//! per-request JOIN (extra work most operators don't need) or a +//! separate batch fetch (extra round-trip). Frontend cross-references +//! `user_id` against its cached user list. + +use chrono::{DateTime, Utc}; +use serde::Serialize; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::domain::entities::session::Session; + +/// Wire shape for `GET /api/admin/sessions`. Contains everything the +/// admin table renders and **nothing the raw session entity would +/// leak** (refresh token, OIDC ID-token, full DPoP thumbprint). +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct SessionSummaryDto { + pub id: Uuid, + pub user_id: Uuid, + pub created_at: DateTime, + pub expires_at: DateTime, + pub ip_address: Option, + pub user_agent: Option, + /// `true` iff the session is DPoP-bound. Rendered as a lock icon + /// in the admin table. Complements the auth-badges surface. + pub is_bound: bool, + /// First 8 chars of the DPoP thumbprint when bound, `None` otherwise. + /// Enough to distinguish two bindings of the same user across + /// devices at a glance; not enough to leak the full jkt. + pub dpop_jkt_prefix: Option, + /// `true` when the row is revoked. Present because the panel has an + /// opt-in "include revoked" checkbox — active-only listings will + /// always show `false` here, but forensics listings need the flag. + pub is_revoked: bool, + /// Whether this row is currently usable — `!revoked && expires_at > now()`. + /// Kept server-side so the SPA doesn't drift if the browser clock is off. + pub is_active: bool, + /// OIDC session identifier when the login came through OIDC and the + /// IdP emitted `sid` — otherwise `None`. Useful when an operator is + /// correlating with the upstream IdP's session log. + pub oidc_sid: Option, +} + +impl From for SessionSummaryDto { + fn from(s: Session) -> Self { + let is_revoked = s.is_revoked(); + let is_expired = s.is_expired(); + let jkt = s.dpop_jkt().map(|s| s.to_owned()); + let dpop_jkt_prefix = jkt + .as_ref() + .map(|t| t.chars().take(8).collect::()); + Self { + id: s.id(), + user_id: s.user_id(), + created_at: s.created_at(), + expires_at: s.expires_at(), + ip_address: s.ip_address().map(str::to_owned), + user_agent: s.user_agent().map(str::to_owned), + is_bound: jkt.is_some(), + dpop_jkt_prefix, + is_revoked, + is_active: !is_revoked && !is_expired, + oidc_sid: s.oidc_sid().map(str::to_owned), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Duration; + + fn base(revoked: bool, jkt: Option<&str>) -> Session { + let mut s = Session::new( + Uuid::new_v4(), + "refresh-token".to_string(), + Some("192.0.2.1".to_string()), + Some("Mozilla/5.0".to_string()), + 30, + Uuid::new_v4(), + ); + if revoked { + s.revoke(); + } + if let Some(k) = jkt { + s = s.with_dpop_jkt(k.to_string()); + } + s + } + + #[test] + fn dto_never_leaks_refresh_token() { + let s = base(false, None); + let dto = SessionSummaryDto::from(s); + let json = serde_json::to_string(&dto).unwrap(); + assert!( + !json.contains("refresh-token"), + "refresh_token must never appear in the wire shape" + ); + } + + #[test] + fn dto_truncates_dpop_jkt_to_8_chars() { + // 44-char base64url thumbprint (SHA-256 → 32 bytes → ceil(32/3)*4 = 44) + let full = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGH"; + let dto = SessionSummaryDto::from(base(false, Some(full))); + assert_eq!(dto.dpop_jkt_prefix.as_deref(), Some("abcdefgh")); + assert!(dto.is_bound); + } + + #[test] + fn dto_unbound_session_has_no_prefix() { + let dto = SessionSummaryDto::from(base(false, None)); + assert_eq!(dto.dpop_jkt_prefix, None); + assert!(!dto.is_bound); + } + + #[test] + fn is_active_false_when_revoked() { + let dto = SessionSummaryDto::from(base(true, None)); + assert!(dto.is_revoked); + assert!(!dto.is_active); + } + + #[test] + fn is_active_true_for_fresh_unrevoked_session() { + let dto = SessionSummaryDto::from(base(false, Some("jkt-abc"))); + assert!(!dto.is_revoked); + assert!(dto.is_active); + } + + #[test] + fn from_raw_expired_session_is_not_active() { + let past = Utc::now() - Duration::days(1); + let s = Session::from_raw( + Uuid::new_v4(), + Uuid::new_v4(), + "rt".to_string(), + past, + None, + None, + past, + false, + Uuid::new_v4(), + None, + None, + None, + ); + let dto = SessionSummaryDto::from(s); + assert!(!dto.is_active); + assert!(!dto.is_revoked); // exp-but-unrevoked distinct from revoked + } +} diff --git a/src/application/dtos/settings_dto.rs b/src/application/dtos/settings_dto.rs index ba7328bf..0d2cc9da 100644 --- a/src/application/dtos/settings_dto.rs +++ b/src/application/dtos/settings_dto.rs @@ -118,6 +118,19 @@ pub struct ListUsersQueryDto { pub summary: Option, } +/// Query parameters for the admin sessions listing. +/// +/// `user_id` is a String (not `Uuid`) because bad UUIDs need a clean +/// 400 response — the handler parses and rejects malformed input. +/// `include_revoked` defaults to `false` at the handler layer. +#[derive(Debug, Serialize, Deserialize)] +pub struct ListSessionsQueryDto { + pub user_id: Option, + pub include_revoked: Option, + pub limit: Option, + pub offset: Option, +} + /// One row of the dashboard's quota panel — usage aggregate for a /// single drive kind. Unlimited caps are excluded from `capped_quota_bytes` /// and counted in `unlimited_count` so the panel can render the ratio diff --git a/src/application/ports/auth_ports.rs b/src/application/ports/auth_ports.rs index d3c81373..cb8564fa 100644 --- a/src/application/ports/auth_ports.rs +++ b/src/application/ports/auth_ports.rs @@ -497,6 +497,24 @@ pub trait SessionStoragePort: Send + Sync + 'static { /// 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>; + + /// Fetch a single session by id. Used by admin surfaces that need + /// to resolve `target_user_id` for audit lines before a mutation. + /// Returns `NotFound` when the id doesn't match any row. + async fn get_session_by_id(&self, session_id: Uuid) -> Result; + + /// Paginated cross-user listing for the admin sessions panel. + /// `user_id_filter` narrows to a single user when `Some`; `None` + /// spans all users. `include_revoked = false` (the default UX) + /// returns only rows where `revoked = false AND expires_at > NOW()`. + /// Ordered newest first (`created_at DESC`). + async fn list_sessions_paginated( + &self, + user_id_filter: Option, + include_revoked: bool, + limit: i64, + offset: i64, + ) -> Result, DomainError>; } // ============================================================================ diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 31abe677..528119c7 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -803,7 +803,12 @@ impl AuthApplicationService { Ok(UserDto::from(created_user)) } - pub async fn login(&self, dto: LoginDto) -> Result { + pub async fn login( + &self, + dto: LoginDto, + client_ip: Option, + user_agent: Option, + ) -> Result { // Gate: policy may forbid password logins entirely (either the // legacy OIDC-only mode or the newer `OXICLOUD_AUTH_METHODS` // allowlist without `password`). Refuse BEFORE the user lookup @@ -1006,7 +1011,7 @@ 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, dto.dpop_jkt) + self.mint_session_for_authenticated_user(user, dto.dpop_jkt, client_ip, user_agent) .await } @@ -1034,6 +1039,8 @@ impl AuthApplicationService { &self, mut user: crate::domain::entities::user::User, dpop_jkt: Option, + client_ip: Option, + user_agent: Option, ) -> Result { // Lifecycle: dispatch login BEFORE register_login() so hooks // observing `last_login_at().is_none()` see "first ever login" @@ -1092,8 +1099,8 @@ impl AuthApplicationService { let mut session = Session::new( user.id(), refresh_token.clone(), - None, // IP (can be added from the HTTP layer) - None, // User-Agent (can be added from the HTTP layer) + client_ip, + user_agent, self.token_service.refresh_token_expiry_days(), Uuid::new_v4(), ); @@ -1168,6 +1175,8 @@ impl AuthApplicationService { token: &str, incoming_challenge: Option<&str>, cross_browser_confirmed: bool, + client_ip: Option, + user_agent: Option, ) -> Result { let repo = self.magic_link_repo.as_ref().ok_or_else(|| { DomainError::new( @@ -1355,8 +1364,8 @@ impl AuthApplicationService { let session = Session::new( user.id(), refresh_token.clone(), - None, - None, + client_ip, + user_agent, self.token_service.refresh_token_expiry_days(), Uuid::new_v4(), ); @@ -1447,6 +1456,8 @@ impl AuthApplicationService { pub async fn refresh_token( &self, dto: RefreshTokenDto, + client_ip: Option, + user_agent: Option, ) -> Result { // Get valid session let session = self @@ -1525,8 +1536,8 @@ impl AuthApplicationService { let mut new_session = Session::new( user.id(), new_refresh_token.clone(), - None, - None, + client_ip, + user_agent, self.token_service.refresh_token_expiry_days(), session.family_id(), ); @@ -2931,6 +2942,78 @@ impl AuthApplicationService { Ok(names.into_iter().flatten().collect()) } + // ======================================================================== + // Admin Session Management Methods + // ======================================================================== + // + // AuthZ posture: /api/admin/* is already protected by a + // `require_admin` router layer (see + // `interfaces/api/routes.rs::admin_router`) — but every admin + // method here still calls `require_admin_caller` as a + // defense-in-depth check, matching the pattern + // `list_users_including_external_with_perms` established. If a + // handler is ever wired outside the /admin subtree, the AuthZ + // still holds. + + /// List sessions for the admin panel. `user_id_filter = Some(uuid)` + /// narrows to one user; `None` returns cross-user. `include_revoked` + /// controls whether to show revoked / expired rows — default UX + /// hides them (checkbox to opt in for forensics). + pub async fn admin_list_sessions_with_perms( + &self, + authorization: &A, + caller_id: Uuid, + user_id_filter: Option, + include_revoked: bool, + limit: i64, + offset: i64, + ) -> Result, DomainError> { + self.require_admin_caller(authorization, caller_id).await?; + let sessions = self + .session_storage + .list_sessions_paginated(user_id_filter, include_revoked, limit, offset) + .await?; + Ok(sessions + .into_iter() + .map(crate::application::dtos::session_dto::SessionSummaryDto::from) + .collect()) + } + + /// Admin-driven session revocation. Sets `revoked = true` — the + /// row remains for audit visibility, but its refresh token is + /// dead and the next access-token refresh 401s naturally. + /// + /// Emits an audit line + counter increment so operators can trace + /// who killed which session and when. + pub async fn admin_revoke_session_with_perms( + &self, + authorization: &A, + caller_id: Uuid, + session_id: Uuid, + ) -> Result<(), DomainError> { + self.require_admin_caller(authorization, caller_id).await?; + // Resolve target user for the audit line before revocation — + // once the session row is revoked the user_id is still readable + // but the ORDER is stable this way. + let target_user_id = self + .session_storage + .get_session_by_id(session_id) + .await + .ok() + .map(|s| s.user_id()); + self.session_storage.revoke_session(session_id).await?; + tracing::info!( + target: "audit", + event = "admin.session_revoked", + caller_id = %caller_id, + session_id = %session_id, + target_user_id = target_user_id.map(|u| u.to_string()).unwrap_or_default(), + "👮🏻‍♂️ Admin revoked session", + ); + metrics::counter!("oxicloud_admin_session_revoked_total").increment(1); + Ok(()) + } + // ======================================================================== // Admin User Management Methods // ======================================================================== @@ -3753,6 +3836,8 @@ impl AuthApplicationService { code: &str, state: &str, locale_registry: &crate::common::locale::LocaleRegistry, + client_ip: Option, + user_agent: Option, ) -> Result { // 0. Validate CSRF state and retrieve PKCE verifier + nonce + optional NC token // (entry is auto-expired by moka TTL — remove returns None if expired) @@ -4276,8 +4361,8 @@ impl AuthApplicationService { let mut session = Session::new( user.id(), refresh_token.clone(), - None, - None, + client_ip, + user_agent, self.token_service.refresh_token_expiry_days(), Uuid::new_v4(), ) @@ -4492,11 +4577,15 @@ mod phase4_gate_integration_tests { let user_id = seed_user_with_password(&pool, &hasher, &email, "s3cret-passphrase").await; // Baseline — no envelope, no migration mark → legacy works. - svc.login(crate::application::dtos::user_dto::LoginDto { - username: email.clone(), - password: "s3cret-passphrase".to_string(), - dpop_jkt: None, - }) + svc.login( + crate::application::dtos::user_dto::LoginDto { + username: email.clone(), + password: "s3cret-passphrase".to_string(), + dpop_jkt: None, + }, + None, + None, + ) .await .expect("baseline legacy login must succeed"); @@ -4510,11 +4599,15 @@ mod phase4_gate_integration_tests { // but AccessDenied with the exact message the handler layer // remaps to `403 OpaqueLoginRequired`. let refused = svc - .login(crate::application::dtos::user_dto::LoginDto { - username: email.clone(), - password: "s3cret-passphrase".to_string(), - dpop_jkt: None, - }) + .login( + crate::application::dtos::user_dto::LoginDto { + username: email.clone(), + password: "s3cret-passphrase".to_string(), + dpop_jkt: None, + }, + None, + None, + ) .await .expect_err("legacy login must be refused post-migration"); assert_eq!( @@ -4534,11 +4627,15 @@ mod phase4_gate_integration_tests { // password check specifically so an attacker without the // password learns nothing about migration state. let wrong = svc - .login(crate::application::dtos::user_dto::LoginDto { - username: email.clone(), - password: "wrong-password".to_string(), - dpop_jkt: None, - }) + .login( + crate::application::dtos::user_dto::LoginDto { + username: email.clone(), + password: "wrong-password".to_string(), + dpop_jkt: None, + }, + None, + None, + ) .await .expect_err("wrong password must still fail"); assert_eq!(wrong.message, "Invalid credentials"); @@ -4551,11 +4648,15 @@ mod phase4_gate_integration_tests { .clear_registration(user_id) .await .expect("clear registration"); - svc.login(crate::application::dtos::user_dto::LoginDto { - username: email, - password: "s3cret-passphrase".to_string(), - dpop_jkt: None, - }) + svc.login( + crate::application::dtos::user_dto::LoginDto { + username: email, + password: "s3cret-passphrase".to_string(), + dpop_jkt: None, + }, + None, + None, + ) .await .expect("legacy login must succeed again after admin clear_registration"); } diff --git a/src/domain/repositories/session_repository.rs b/src/domain/repositories/session_repository.rs index 48589d4e..8a2a2086 100644 --- a/src/domain/repositories/session_repository.rs +++ b/src/domain/repositories/session_repository.rs @@ -58,6 +58,23 @@ pub trait SessionRepository: Send + Sync + 'static { async fn get_sessions_by_user_id(&self, user_id: Uuid) -> SessionRepositoryResult>; + /// Paginated listing for the admin sessions panel. Cross-user by + /// default; `user_id_filter = Some(uuid)` narrows to one user. + /// `include_revoked = false` (the default UX) filters to sessions + /// that are BOTH non-revoked AND non-expired — what an operator + /// would call "active right now". `include_revoked = true` shows + /// everything for incident forensics. + /// + /// Ordered by `created_at DESC` — newest first, matching the + /// existing `get_sessions_by_user_id` convention. + async fn list_sessions_paginated( + &self, + user_id_filter: Option, + include_revoked: bool, + limit: i64, + offset: i64, + ) -> SessionRepositoryResult>; + /// Revokes a specific session async fn revoke_session(&self, session_id: Uuid) -> SessionRepositoryResult<()>; diff --git a/src/infrastructure/repositories/pg/session_pg_repository.rs b/src/infrastructure/repositories/pg/session_pg_repository.rs index 4da55abe..6f6778d6 100644 --- a/src/infrastructure/repositories/pg/session_pg_repository.rs +++ b/src/infrastructure/repositories/pg/session_pg_repository.rs @@ -223,6 +223,66 @@ impl SessionRepository for SessionPgRepository { Ok(sessions) } + async fn list_sessions_paginated( + &self, + user_id_filter: Option, + include_revoked: bool, + limit: i64, + offset: i64, + ) -> SessionRepositoryResult> { + // Single SQL with nullable-user-id + include-revoked flag + // baked in as parameters, rather than four hand-forked + // queries. `$1::uuid IS NULL` short-circuits when no filter is + // set; `$2 OR (revoked = false AND expires_at > NOW())` folds + // the active-only rule into one predicate. Both branches use + // the same index (`idx_sessions_user_id`) on the filtered + // path, and a full table scan bounded by `LIMIT` on the + // unfiltered path — acceptable for an admin-triggered view + // that operators paginate through. + let rows = sqlx::query( + r#" + SELECT + id, user_id, refresh_token, expires_at, + ip_address, user_agent, created_at, revoked, family_id, + oidc_id_token, oidc_sid, dpop_jkt + FROM auth.sessions + WHERE ($1::uuid IS NULL OR user_id = $1) + AND ($2 OR (revoked = false AND expires_at > NOW())) + ORDER BY created_at DESC + LIMIT $3 OFFSET $4 + "#, + ) + .bind(user_id_filter) + .bind(include_revoked) + .bind(limit) + .bind(offset) + .fetch_all(&*self.pool) + .await + .map_err(Self::map_sqlx_error)?; + + let sessions = rows + .into_iter() + .map(|row| { + Session::from_raw( + row.get("id"), + row.get("user_id"), + row.get("refresh_token"), + row.get("expires_at"), + row.get("ip_address"), + row.get("user_agent"), + row.get("created_at"), + row.get("revoked"), + row.get("family_id"), + row.get("oidc_id_token"), + row.get("oidc_sid"), + row.get("dpop_jkt"), + ) + }) + .collect(); + + Ok(sessions) + } + /// Revokes a specific session using a transaction async fn revoke_session(&self, session_id: Uuid) -> SessionRepositoryResult<()> { let id = session_id; // Copy for use in closure @@ -649,4 +709,28 @@ impl SessionStoragePort for SessionPgRepository { .await .map_err(DomainError::from) } + + async fn get_session_by_id(&self, session_id: Uuid) -> Result { + SessionRepository::get_session_by_id(self, session_id) + .await + .map_err(DomainError::from) + } + + async fn list_sessions_paginated( + &self, + user_id_filter: Option, + include_revoked: bool, + limit: i64, + offset: i64, + ) -> Result, DomainError> { + SessionRepository::list_sessions_paginated( + self, + user_id_filter, + include_revoked, + limit, + offset, + ) + .await + .map_err(DomainError::from) + } } diff --git a/src/interfaces/api/handlers/admin_handler.rs b/src/interfaces/api/handlers/admin_handler.rs index 373e51e8..cea20273 100644 --- a/src/interfaces/api/handlers/admin_handler.rs +++ b/src/interfaces/api/handlers/admin_handler.rs @@ -17,7 +17,8 @@ use crate::application::dtos::plugin_dto::{ }; use crate::application::dtos::settings_dto::{ AdminCreateUserDto, AdminResetPasswordDto, DashboardStatsDto, DriveKindUsageDto, - ListUsersQueryDto, MigrationStateDto, SaveOidcSettingsDto, SaveStorageSettingsDto, + ListSessionsQueryDto, ListUsersQueryDto, MigrationStateDto, SaveOidcSettingsDto, + SaveStorageSettingsDto, SendSmtpTestDto, SmtpInfoDto, SmtpTestResultDto, StartMigrationDto, TestOidcConnectionDto, TestStorageConnectionDto, UpdateUserActiveDto, UpdateUserQuotaDto, UpdateUserRoleDto, }; @@ -108,6 +109,11 @@ pub fn admin_routes() -> Router> { .route("/users", post(create_user)) .route("/users/{id}", get(get_user)) .route("/users/{id}", delete(delete_user)) + // Session management (DPoP admin panel — see docs/plan/dpop.md + // Gate 10). List is global cross-user with `?user_id=` narrow; + // revoke sets `revoked=true` (row stays for audit). + .route("/sessions", get(list_sessions)) + .route("/sessions/{id}", delete(revoke_session)) .route("/users/{id}/role", put(update_user_role)) .route("/users/{id}/active", put(update_user_active)) .route("/users/{id}/quota", put(update_user_quota)) @@ -1189,6 +1195,106 @@ pub async fn delete_user( )) } +/// GET /api/admin/sessions?user_id=&include_revoked=&limit=&offset= — list sessions +/// +/// Global cross-user listing by default. `user_id` narrows to one +/// user; omit for cross-user. `include_revoked=true` opts into +/// showing revoked / expired rows for forensics (default hides). +/// Response is `{sessions, limit, offset}` — no total count (would +/// require a second scan; the panel paginates on presence of +/// exactly `limit` rows returned). +#[utoipa::path( + get, + path = "/api/admin/sessions", + params( + ("user_id" = Option, Query, description = "Narrow to one user (UUID); omit for cross-user"), + ("include_revoked" = Option, Query, description = "Include revoked + expired rows (default false — active only)"), + ("limit" = Option, Query, description = "Max rows to return (default 100, max 500)"), + ("offset" = Option, Query, description = "Pagination offset") + ), + responses( + (status = 200, description = "List of sessions"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required") + ), + security(("bearerAuth" = [])), + tag = "admin" +)] +pub async fn list_sessions( + State(state): State>, + auth_user: AuthUser, + Query(query): Query, +) -> Result { + let auth = state + .auth_service + .as_ref() + .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; + + let limit = query.limit.unwrap_or(100).min(500); + let offset = query.offset.unwrap_or(0); + let include_revoked = query.include_revoked.unwrap_or(false); + let user_id_filter = match query.user_id.as_deref() { + Some(s) => Some(Uuid::parse_str(s).map_err(|_| AppError::bad_request("Invalid user_id"))?), + None => None, + }; + + let sessions = auth + .auth_application_service + .admin_list_sessions_with_perms( + state.authorization.as_ref(), + auth_user.id, + user_id_filter, + include_revoked, + limit, + offset, + ) + .await + .map_err(AppError::from)?; + + Ok(Json(serde_json::json!({ + "sessions": sessions, + "limit": limit, + "offset": offset, + }))) +} + +/// DELETE /api/admin/sessions/:id — revoke a session +#[utoipa::path( + delete, + path = "/api/admin/sessions/{id}", + params(("id" = String, Path, description = "Session UUID")), + responses( + (status = 200, description = "Session revoked"), + (status = 400, description = "Invalid UUID"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required"), + (status = 404, description = "Session not found") + ), + security(("bearerAuth" = [])), + tag = "admin" +)] +pub async fn revoke_session( + State(state): State>, + auth_user: AuthUser, + Path(id): Path, +) -> Result { + let session_id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?; + let auth = state + .auth_service + .as_ref() + .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; + + auth.auth_application_service + .admin_revoke_session_with_perms(state.authorization.as_ref(), auth_user.id, session_id) + .await + .map_err(AppError::from)?; + + Ok(( + StatusCode::OK, + Json(serde_json::json!({ "message": "Session revoked" })), + )) +} + /// PUT /api/admin/users/:id/role — change user role #[utoipa::path( put, diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index 3861db43..6b43fd2e 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -393,10 +393,19 @@ pub async fn login( )); } + // Extract the User-Agent once — the audit lines already carry + // `client_ip` on the request-scope span; passing both to the + // service lets `create_session` capture them on the row so the + // admin panel can show *who logged in from where*. + let user_agent = headers + .get(axum::http::header::USER_AGENT) + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + // Try the normal login process match auth_service .auth_application_service - .login(dto.clone()) + .login(dto.clone(), Some(client_ip.clone()), user_agent.clone()) .await { Ok(auth_response) => { @@ -547,6 +556,7 @@ pub async fn login( )] pub async fn refresh_token( State(state): State>, + ConnectInfo(peer): ConnectInfo, headers: HeaderMap, body: axum::body::Bytes, ) -> Result { @@ -568,9 +578,19 @@ pub async fn refresh_token( refresh_token: refresh_tok, }; + // Refresh rotates the session row — capture current IP + UA so the + // NEW row's `ip_address`/`user_agent` reflect the latest observed + // client (see `sessions.rotate_session`). Old row keeps its own + // capture from creation time. + let client_ip = 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); + let auth_response = auth_service .auth_application_service - .refresh_token(dto) + .refresh_token(dto, Some(client_ip), user_agent) .await?; tracing::info!("Token refresh successful, new token issued"); @@ -1560,6 +1580,8 @@ pub async fn oidc_unlink( )] pub async fn oidc_callback( State(state): State>, + ConnectInfo(peer): ConnectInfo, + headers: HeaderMap, Query(query): Query, ) -> Result { let auth_service = state @@ -1579,6 +1601,16 @@ pub async fn oidc_callback( tracing::info!("OIDC callback received with code"); + // Capture IP + UA so the OIDC-minted session row lands populated + // (admin panel would otherwise show "—" for SSO logins). Callback + // is a browser-initiated GET after the IdP redirect, so peer is + // the browser and User-Agent is the browser's. + let client_ip = 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); + // Exchange code, validate state/nonce/PKCE, authenticate user. // Any Err path (expired state on refresh, consumed code on replay, // anti-takeover email refusal, etc.) is caught below and turned @@ -1587,7 +1619,13 @@ pub async fn oidc_callback( // mid-navigation from the IdP, not the SPA. The SPA login page // renders localized copy per key. let result = match auth_app - .oidc_callback(&query.code, &query.state, &state.locale_registry) + .oidc_callback( + &query.code, + &query.state, + &state.locale_registry, + Some(client_ip), + user_agent, + ) .await { Ok(r) => r, diff --git a/src/interfaces/api/handlers/magic_link_handler.rs b/src/interfaces/api/handlers/magic_link_handler.rs index 3c2738af..89dbf60d 100644 --- a/src/interfaces/api/handlers/magic_link_handler.rs +++ b/src/interfaces/api/handlers/magic_link_handler.rs @@ -148,6 +148,7 @@ struct RedeemQuery { )] async fn redeem_magic_link( State(state): State>, + axum::extract::ConnectInfo(peer): axum::extract::ConnectInfo, Path(token): Path, Query(query): Query, RequestLocale(locale): RequestLocale, @@ -171,12 +172,26 @@ async fn redeem_magic_link( .map(|v| v == "1" || v == "true") .unwrap_or(false); + // Capture IP + UA for the newly minted session row (admin sessions + // panel renders these; NULLs would show as "—"). + 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); + match auth_svc .auth_application_service .redeem_magic_link( &token, incoming_challenge.as_deref(), cross_browser_confirmed, + Some(client_ip), + user_agent, ) .await { diff --git a/src/interfaces/api/handlers/opaque_auth_handler.rs b/src/interfaces/api/handlers/opaque_auth_handler.rs index e6870ad7..f9a4525f 100644 --- a/src/interfaces/api/handlers/opaque_auth_handler.rs +++ b/src/interfaces/api/handlers/opaque_auth_handler.rs @@ -682,6 +682,8 @@ pub async fn login_ke1( )] 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)?; @@ -764,12 +766,23 @@ pub async fn login_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) + .mint_session_for_authenticated_user(user, dto.dpop_jkt, Some(client_ip), user_agent) .await .map_err(AppError::from)?; From 10d831b204b6315fb2dfe422a6034b7c40e6a809 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 9 Aug 2026 05:00:30 +0200 Subject: [PATCH 26/51] feat(session): ensure dpop even with OIDC --- frontend/src/lib/api/types.ts | 5 ++ frontend/src/lib/stores/session.svelte.ts | 16 +++++-- .../src/routes/admin/[[tab]]/+page.svelte | 47 +++++++++++++++---- frontend/src/routes/login/+page.svelte | 9 ++++ src/application/dtos/session_dto.rs | 23 +++++++++ .../services/auth_application_service.rs | 8 +++- src/interfaces/api/handlers/admin_handler.rs | 9 ++++ 7 files changed, 103 insertions(+), 14 deletions(-) diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 51ee665e..37f8ca14 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -754,6 +754,11 @@ export interface SessionSummary { is_revoked: boolean; is_active: boolean; oidc_sid: string | null; + /** `true` when this row IS the admin's currently-active session — + * compared server-side by `dpop_jkt`. Panel uses this to warn + * before revoking ("this will log you out"). Always `false` when + * the admin's own session is unbound. */ + is_current: boolean; } /** Wire response of `GET /api/admin/sessions`. */ diff --git a/frontend/src/lib/stores/session.svelte.ts b/frontend/src/lib/stores/session.svelte.ts index 8a78e6cc..86148641 100644 --- a/frontend/src/lib/stores/session.svelte.ts +++ b/frontend/src/lib/stores/session.svelte.ts @@ -6,7 +6,7 @@ * routing: externals (magic-link / OIDC-only / OCM recipients) have no home * folder and land on the shared-with-me view. */ -import { fetchMe, tryRefresh } from '$lib/api/endpoints/auth'; +import { bindDpopIfPossible, fetchMe, tryRefresh } from '$lib/api/endpoints/auth'; import { drives } from '$lib/stores/drives.svelte'; import type { User } from '$lib/api/types'; import { ensureActiveUser } from '$lib/utils/localStoragePrefs'; @@ -45,8 +45,18 @@ class SessionStore { if (!me && (await tryRefresh())) { me = await fetchMe(); } - if (me) this.setUser(me); - else this.user = null; + if (me) { + this.setUser(me); + // Post-redirect DPoP bind — catches OIDC / magic-link + // flows whose server-side callback creates the session + // UNBOUND (no way for the redirect to carry the JKT in + // the callback body). One-shot per SPA lifetime because + // `this.loaded` guard makes `load()` a singleton; + // server returns 409 if the session is already bound + // (harmless — result is swallowed). Fire-and-forget so + // a slow IndexedDB open doesn't stall the app boot. + void bindDpopIfPossible(); + } else this.user = null; } catch { this.user = null; } diff --git a/frontend/src/routes/admin/[[tab]]/+page.svelte b/frontend/src/routes/admin/[[tab]]/+page.svelte index 081979c6..5621f386 100644 --- a/frontend/src/routes/admin/[[tab]]/+page.svelte +++ b/frontend/src/routes/admin/[[tab]]/+page.svelte @@ -879,16 +879,21 @@ } } - async function onRevokeSession(id: string) { - if ( - !confirm( - t( + async function onRevokeSession(id: string, isCurrent: boolean) { + // Escalated warning for the caller's own session — revoking it + // bricks the tab (all subsequent requests 401 → nav-guard bounces + // to /login). A plain "are you sure" was too easy to click + // through by muscle memory on a table of revoke buttons. + const message = isCurrent + ? t( + 'admin.sessions.revoke_self_confirm', + "⚠️ This is YOUR current session. Revoking it will log YOU out immediately and you'll have to sign back in. Continue?" + ) + : t( 'admin.sessions.revoke_confirm', 'Revoke this session? The next request from that browser will 401.' - ) - ) - ) - return; + ); + if (!confirm(message)) return; sessionRevokingId = id; try { await revokeAdminSession(id); @@ -3024,8 +3029,22 @@ - {s.user_id.slice(0, 8)}… + + {s.user_id.slice(0, 8)}… + {#if s.is_current} + + {t('admin.sessions.current', 'you')} + + {/if} + {new Date(s.created_at).toLocaleString()} {new Date(s.expires_at).toLocaleString()} {s.ip_address ?? '—'} @@ -3065,7 +3084,7 @@