diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea7d42c4..b880195c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,7 +89,15 @@ jobs: migration-ordering: name: Migration ordering (new migrations postdate target branch) needs: changes - if: needs.changes.outputs.migrations == 'true' + # `github.base_ref` is only populated on `pull_request` events — + # on `push` triggers it collapses to an empty string, which makes + # every `origin/${BASE_REF}` ref resolve to literal `origin/` and + # the job fails with `fatal: Not a valid object name origin/`. + # The ordering check is a PR-diff check by construction (compare + # a proposed migration against the TARGET branch's tip), so + # scoping it to pull_request events is both correct and cheaper — + # push-only events don't need the double-fire either. + if: github.event_name == 'pull_request' && needs.changes.outputs.migrations == 'true' runs-on: ubuntu-latest steps: - name: Checkout PR branch with full history diff --git a/.github/workflows/playwright-update-snapshots.yml b/.github/workflows/playwright-update-snapshots.yml deleted file mode 100644 index cadc24a5..00000000 --- a/.github/workflows/playwright-update-snapshots.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Playwright — Update snapshots - -on: - workflow_dispatch: - -jobs: - update-snapshots: - timeout-minutes: 60 - runs-on: ubuntu-latest - defaults: - run: - working-directory: tests/e2e - steps: - - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Cache Rust build - uses: Swatinem/rust-cache@v2 - - - uses: actions/setup-node@v4 - with: - node-version: lts/* - - - name: Install Node dependencies - run: npm ci - - - name: Build OxiCloud (release) - working-directory: . - run: cargo build --release - - - name: Install Playwright browsers - run: npx playwright install --with-deps - - - name: Update snapshots - run: npm test -- --update-snapshots=all - env: - BUILD_TARGET: release - - - name: Commit updated snapshots - uses: stefanzweifel/git-auto-commit-action@v5 - with: - commit_message: "test(e2e): update playwright linux snapshots" - file_pattern: "tests/e2e/scenarios/**/*-linux.png" diff --git a/Cargo.lock b/Cargo.lock index db5d4d9c..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", @@ -4490,6 +4590,7 @@ dependencies = [ "nom-exif", "opaque-ke", "ort", + "p256", "pdf-extract", "percent-encoding", "quick-xml 0.41.0", @@ -4986,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" @@ -5219,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" @@ -5656,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" @@ -5968,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" @@ -6403,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 a1cf19fb..346ba04a 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"] } @@ -130,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 = [] @@ -196,6 +209,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/docs/config/env.md b/docs/config/env.md index 5a300ded..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 @@ -62,6 +63,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/docs/index.md b/docs/index.md index b7889a62..2295e949 100644 --- a/docs/index.md +++ b/docs/index.md @@ -34,7 +34,7 @@ features: details: Edit documents in Collabora Online or OnlyOffice directly in the browser. - icon: 🔐 title: Security First - details: JWT + Argon2id, OIDC/SSO (Keycloak, Authentik, Azure AD), role-based access, shared links. + details: JWT + DPoP + Opaque (Argon2id), OIDC/SSO (Keycloak, Authentik, Azure AD), role-based access, shared links. - icon: 🌍 title: 14 Languages details: EN, ES, DE, FR, IT, PT, NL, ZH, JA, KO, AR, HI, FA, RU — and growing. diff --git a/docs/plan/dpop.md b/docs/plan/dpop.md new file mode 100644 index 00000000..eac7fb2b --- /dev/null +++ b/docs/plan/dpop.md @@ -0,0 +1,321 @@ +# 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 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: + +- `` — 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 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 + +- **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. +- **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). + +--- + +## 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) | +| C — Content-serve + streaming allowlist | 0.5 day (shipped alongside Gate 9) | +| 10 — Observability + admin UX | 1 day | +| **Total (Phase 1)** | **~12 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/example.env b/example.env index 3ac30d60..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. @@ -250,6 +262,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 # ----------------------------------------------------------------------------- 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/frontend/src/hooks.client.ts b/frontend/src/hooks.client.ts index fb1de1a3..38c815c6 100644 --- a/frontend/src/hooks.client.ts +++ b/frontend/src/hooks.client.ts @@ -6,6 +6,7 @@ import { setSessionExpiredHandler } from '$lib/api/client'; import { initI18n } from '$lib/i18n/index.svelte'; import { session } from '$lib/stores/session.svelte'; +import { seedNonceFromCookie } from '$lib/auth/dpop-proof'; export async function init(): Promise { setSessionExpiredHandler(() => { @@ -15,5 +16,13 @@ export async function init(): Promise { } }); + // Consume the one-shot `oxicloud_dpop_nonce` cookie the backend + // stamps on every login-success response — critical for redirect- + // flow logins (OIDC callback, magic-link finish) where the browser + // lands here BEFORE any client-side login handler has run. Without + // this, the layout's `session.load()` fetchMe would be the first + // bound request and eat a `use_dpop_nonce` 401 → retry cycle. + seedNonceFromCookie(); + await initI18n(); } 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..8f3f287a 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 @@ -30,7 +35,10 @@ const SERVER_STATUS_HEADER = 'x-server-status'; const REFRESH_ENDPOINT = '/api/auth/refresh'; -/** Auth primitives — a 401 here is genuine, never an expired access token. */ +/** Auth primitives — a 401 here is genuine, never an expired access token. + * Also used by the session-teardown gate to exempt endpoints that must + * still fire during / immediately after a logout (the logout POST itself, + * and every login path a user might retry on the /login landing). */ const AUTH_PRIMITIVES = [ '/api/auth/login', '/api/auth/logout', @@ -38,7 +46,11 @@ const AUTH_PRIMITIVES = [ '/api/auth/register', '/api/auth/setup', '/api/auth/oidc/', - '/api/auth/device/' + '/api/auth/device/', + '/api/auth/opaque/', + '/api/auth/magic-link/', + '/api/auth/status', + '/api/auth/dpop/' ]; export type FetchFn = typeof fetch; @@ -94,7 +106,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 +122,71 @@ 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); + // Session-teardown short-circuit. While a logout is in flight (or + // the caller has already navigated to /login post-logout without + // re-authenticating), the session is dead — any subscriber-fired + // refresh (`session.load()` in the layout, a store `$effect` re- + // fetching its slice, an idle poll) would hit /me → 401 → refresh + // → 401 → sessionExpiredHandler and clobber the friendly + // "logged out" landing with `?source=session_expired`. Fail these + // fast with an AbortError so callers unwrap cleanly via their + // existing `.catch` blocks and no server hop occurs. The auth + // primitives themselves (notably `/api/auth/logout`) are exempt so + // the logout POST that FLIPPED the gate can still complete. + const urlStrEarly = urlString(input as RequestInfo | URL); + if (logoutInProgress && !bypassesRetry(urlStrEarly)) { + throw new DOMException('Session terminated', 'AbortError'); + } + 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 +242,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 +254,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 = () => { @@ -189,24 +278,36 @@ export function setSessionExpiredHandler(fn: () => void): void { sessionExpiredHandler = fn; } -// Logout-in-progress gate. Set to true by the logout endpoint wrapper -// (endpoints/auth.ts) for the duration of the POST /api/auth/logout -// call; reset in its `finally`. While set, `sessionExpiredHandler` -// is suppressed — an ambient 401 during the logout window is expected -// (the backend clears cookies and revokes the session as part of the -// logout response, so any in-flight fetch racing the logout will 401), -// and firing the handler would navigate to `/login?source=session_expired` -// mid-flight, cancelling the logout POST before we get its response -// body. Since the response body carries `post_logout_url` (the IdP's -// end_session_endpoint URL for OIDC-linked sessions), losing it means -// the browser never redirects to the IdP and the SSO session persists. -// See AppShell.svelte::onLogout for the caller-side counterpart. +// Session-teardown gate. Flipped ON by `AppShell::onLogout` immediately +// BEFORE it calls `logout()` and left ON across the redirect to /login +// (module state persists over SvelteKit soft nav — a hard reload wipes +// it back to `false`, which is the correct default for a fresh session). +// While set: +// 1. `apiFetch` short-circuits every non-auth-primitive request with +// an `AbortError` — no server hop, no 401, no audit noise. Callers +// unwrap through their existing `.catch` blocks. +// 2. On a 401 the `sessionExpiredHandler` divert is suppressed so it +// cannot clobber the friendly `/login?source=logged_out` landing +// with `?source=session_expired`. +// Rule (1) alone would defeat the logout POST itself, so the auth +// primitives (`/api/auth/logout`, `/api/auth/refresh`, …) are exempted +// via `bypassesRetry`. Rule (2) additionally covers the tail-end race +// where the logout response's `post_logout_url` matters for OIDC — an +// ambient 401 mid-flight cannot cancel the pending POST and swallow +// its body, which would leave the IdP session live. let logoutInProgress = false; export function setLogoutInProgress(value: boolean): void { logoutInProgress = value; } +/** Read-only view of the gate — used by cross-tab handlers to distinguish + * OUR logout (already handled by AppShell.onLogout with source=logged_out) + * from ANOTHER tab's logout (which needs a bare redirect). */ +export function isLogoutInProgress(): boolean { + return logoutInProgress; +} + // Same shape as `sessionExpiredHandler` — mutable so the app can install // the real behaviour post-mount, and a fallback for the (rare) case // where no handler is wired yet (bootstrap, tests). The fallback does diff --git a/frontend/src/lib/api/csrf.ts b/frontend/src/lib/api/csrf.ts index 1d1549b9..e791745d 100644 --- a/frontend/src/lib/api/csrf.ts +++ b/frontend/src/lib/api/csrf.ts @@ -17,3 +17,20 @@ export function getCsrfHeaders(): Record { const token = getCsrfToken(); return token ? { 'X-CSRF-Token': token } : {}; } + +/** + * Best-effort "does the browser think it has a session?" hint. The server + * sets `oxicloud_csrf` (non-HttpOnly, JS-visible) alongside the session + * cookies on every login and clears it on logout, so its ABSENCE is a + * reliable proof of "no session" — cheaper than a network probe that + * would 401 → refresh 401 → 401 on first landing with no cookies. + * + * Its PRESENCE is only a hint: the session cookies (HttpOnly) may have + * been revoked server-side while the CSRF cookie lingers. Callers that + * see `true` must still probe /api/auth/me — this helper just lets a + * fresh no-cookie bootstrap skip the doomed 2× /me + /refresh burst. + */ +export function hasSessionHint(): boolean { + if (typeof document === 'undefined') return false; + return document.cookie.split('; ').some((row) => row.startsWith('oxicloud_csrf=')); +} 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/endpoints/auth.test.ts b/frontend/src/lib/api/endpoints/auth.test.ts index 2fae11a7..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 @@ -35,7 +48,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 @@ -43,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)); @@ -63,16 +82,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); }); @@ -422,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/auth.ts b/frontend/src/lib/api/endpoints/auth.ts index 6c0ac025..1e8fff43 100644 --- a/frontend/src/lib/api/endpoints/auth.ts +++ b/frontend/src/lib/api/endpoints/auth.ts @@ -3,7 +3,7 @@ * primitives here intentionally bypass it (see client.ts) so a 401 surfaces as * a genuine failure to the caller. */ -import { ApiError, apiFetch, setLogoutInProgress } from '$lib/api/client'; +import { ApiError, apiFetch } from '$lib/api/client'; import { getCsrfHeaders } from '$lib/api/csrf'; import type { AuthResponse, User } from '$lib/api/types'; @@ -36,9 +36,48 @@ 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' }); + // 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 { + dpopMod = await import('$lib/auth/dpop-proof'); + } catch { + /* no dpop module → plain fetch */ + } + 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; @@ -48,22 +87,118 @@ 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; } } +/** + * 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 }) + }); + if (res.ok) { + // Bind attached the thumbprint to the SESSION row — but the + // browser is still carrying the JWT issued at OIDC callback + // / magic-link redemption BEFORE that bind, so its + // `cnf.jkt` claim is empty. Force a refresh cycle: the + // `rotate_session` path preserves the DPoP binding on the + // new row and mints a fresh JWT whose `cnf.jkt` reflects + // it. Without this, downstream code that keys off + // `CurrentUser::dpop_jkt` (admin sessions "is_current" + // highlight, DPoP verifier's expected_jkt lookup) sees + // None and treats the caller as unbound. + await tryRefresh(); + } + 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 +233,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 +266,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 @@ -425,32 +564,44 @@ export async function unlinkOidc(): Promise { } export async function logout(): Promise { - // Gate the session-expired handler for the duration of this call. - // The backend revokes the session + clears cookies as part of the - // logout response, so any in-flight fetch racing us will 401. Without - // the gate, that ambient 401 would trigger a navigation to - // `/login?source=session_expired`, cancel the pending logout POST, - // and swallow the `post_logout_url` response body — leaving the SSO - // session live on the IdP because we never navigate to its - // end_session_endpoint. See client.ts `logoutInProgress` for details. - setLogoutInProgress(true); + // The session-teardown gate (`setLogoutInProgress(true)`) is flipped + // by the CALLER (`AppShell::onLogout`) BEFORE this function runs, and + // left ON across the goto to /login. See `client.ts::logoutInProgress` + // for what the gate suppresses (short-circuits ambient fetches with + // AbortError + blocks the session-expired divert). + const res = await apiFetch('/api/auth/logout', { + method: 'POST', + credentials: 'same-origin', + 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 res = await apiFetch('/api/auth/logout', { - method: 'POST', - credentials: 'same-origin', - headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, - body: '{}' - }); - if (!res.ok) return {}; - try { - const body = (await res.json()) as { post_logout_url?: unknown }; - return typeof body?.post_logout_url === 'string' - ? { postLogoutUrl: body.post_logout_url } - : {}; - } catch { - return {}; - } - } finally { - setLogoutInProgress(false); + 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); + } + + if (!res.ok) return {}; + try { + const body = (await res.json()) as { post_logout_url?: unknown }; + return typeof body?.post_logout_url === 'string' ? { postLogoutUrl: body.post_logout_url } : {}; + } catch { + return {}; } } 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 + }); +}); diff --git a/frontend/src/lib/api/endpoints/files.ts b/frontend/src/lib/api/endpoints/files.ts index fd6e37db..245beb99 100644 --- a/frontend/src/lib/api/endpoints/files.ts +++ b/frontend/src/lib/api/endpoints/files.ts @@ -62,61 +62,123 @@ 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/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/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index f8cf8aed..253ec029 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -256,6 +256,20 @@ export interface User { * card). */ has_password?: boolean; + /** + * TRUE when the caller's current session is DPoP-bound (row's + * `dpop_jkt IS NOT NULL`). Populated only by `/api/auth/me`; other + * User-emitting endpoints leave it unset. + * + * The session store reads this to skip a redundant + * `POST /api/auth/dpop/bind` call — the endpoint returns 409 + * `already_bound` on repeated attempts (anti-downgrade invariant) + * and each rejection logs at audit INFO, so a naive "bind on + * every load" pattern was cluttering the audit stream. We only + * fire bind now when there's actual work to do (fresh OIDC / + * magic-link session that landed unbound). + */ + is_dpop_bound?: boolean; } /** Fields rendered by the paginated admin table. Full account details remain @@ -734,3 +748,46 @@ 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; + /** How this session was minted. `unknown` covers pre-migration + * rows and any origin the SPA doesn't yet render. Server enum + * is populated at INSERT (see `Session::new`) and copied on + * refresh. Snake_case wire values map to the labels rendered + * in the admin table. */ + origin: 'password' | 'opaque' | 'magic_link' | 'oidc' | 'device' | 'unknown'; + /** `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`. */ +export interface AdminSessionsPage { + sessions: SessionSummary[]; + limit: number; + offset: number; + /** Access-token TTL in seconds — from `OXICLOUD_ACCESS_TOKEN_EXPIRY_SECS` + * server-side. The panel surfaces this in a "revoke takes effect within + * {N} seconds" notice because revoking flips the DB row (breaks refresh) + * but any in-flight JWT stays valid until its `exp`. */ + access_token_expiry_secs: number; +} 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..c55dda5a --- /dev/null +++ b/frontend/src/lib/auth/dpop-proof.ts @@ -0,0 +1,197 @@ +/** + * 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 { + 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 { + sessionStorage.setItem(NONCE_STORAGE_KEY, fresh); + } catch { + /* sessionStorage full / disabled — keep in-memory copy */ + } +} + +/** + * Read the one-shot `oxicloud_dpop_nonce` cookie the backend stamps on + * every login-success response (POST OPAQUE/legacy AND 302 OIDC/magic- + * link), seed the local nonce cache with it, then clear the cookie so a + * later flow can't reuse a stale value. + * + * Call at SPA boot AND from any client-side login-success path + * (`session.setUser()`). The cookie is set by the server unconditionally + * on login when `dpop_mode != off`; if the client lacks DPoP support this + * call is a harmless no-op (the seeded nonce is never used). + * + * SameSite=Strict + non-HttpOnly on the server side — see + * `cookie_auth::maybe_append_dpop_nonce_cookie` in the backend. + */ +export function seedNonceFromCookie(): void { + if (typeof document === 'undefined') return; + const match = document.cookie.split('; ').find((row) => row.startsWith('oxicloud_dpop_nonce=')); + if (!match) return; + const value = match.split('=')[1] ?? ''; + if (value) updateNonceFromHeader(value); + // Single-shot: expire the cookie so a stale value can't confuse a + // later flow (or, worse, land in the DPoP proof after the nonce has + // rotated server-side past its pool TTL). Path + SameSite must match + // the set-cookie for the browser to accept the deletion. + document.cookie = 'oxicloud_dpop_nonce=; SameSite=Strict; Path=/; Max-Age=0'; +} + +/** 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); +} 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(); + } +} 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/lib/components/AppShell.svelte b/frontend/src/lib/components/AppShell.svelte index b1d4f788..6200c1ec 100644 --- a/frontend/src/lib/components/AppShell.svelte +++ b/frontend/src/lib/components/AppShell.svelte @@ -4,6 +4,7 @@ import { resolve } from '$app/paths'; import { page } from '$app/state'; import { logout } from '$lib/api/endpoints/auth'; + import { setLogoutInProgress } from '$lib/api/client'; import { searchResources } from '$lib/api/endpoints/search'; import { fileInlineUrl, deleteFile } from '$lib/api/endpoints/files'; import { deleteFolder } from '$lib/api/endpoints/folders'; @@ -86,10 +87,16 @@ 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'), - icon: 'folder', + icon: 'hdd', section: 'admin-drives' }, { @@ -101,7 +108,7 @@ { href: '/admin/oidc', label: t('admin.oidc', 'OIDC / SSO'), - icon: 'key', + icon: 'building-shield', section: 'admin-oidc' }, { @@ -488,6 +495,14 @@ } async function onLogout() { + // Flip the session-teardown gate BEFORE the logout POST so every + // ambient/subscriber-fired fetch that fires between here and the + // /login mount short-circuits with AbortError instead of hitting + // the server (see `client.ts::logoutInProgress`). Left ON across + // the goto — module state persists over soft nav, so a stale + // reactive re-fetch during the transition still no-ops. A hard + // reload later (or the IdP round-trip below) wipes it naturally. + setLogoutInProgress(true); let postLogoutUrl: string | undefined; try { ({ postLogoutUrl } = await logout()); @@ -498,18 +513,18 @@ // Full-page navigation to the IdP end-session endpoint. Do NOT // touch local session state first: `session.reset()` fires the // layout $effect guard which races us with a competing - // `goto('/login?redirect=...')`, and any ambient in-flight - // fetch that 401s trips the sessionExpiredHandler with yet - // another navigation to `/login?source=session_expired`. Two - // or three concurrent navigations cancel each other and the - // browser stalls on the current page. The IdP round-trip lands - // us back on `/login` where the SPA reboots fresh from scratch — + // `goto('/login?redirect=...')`. The IdP round-trip lands us + // back on `/login` where the SPA reboots fresh from scratch — // no local cleanup needed here. window.location.replace(postLogoutUrl); return; } session.reset(); - await goto(resolve('/login')); + // `?source=logged_out` distinguishes the friendly explicit-logout + // landing from `?source=session_expired` (auto-divert on 401 → + // refresh 401). The login page reads the flag, shows the success + // notice, and skips its existing-session probe. + await goto(resolve('/login?source=logged_out')); } diff --git a/frontend/src/lib/components/AppShell.test.ts b/frontend/src/lib/components/AppShell.test.ts index 3068940f..1439ecff 100644 --- a/frontend/src/lib/components/AppShell.test.ts +++ b/frontend/src/lib/components/AppShell.test.ts @@ -54,13 +54,19 @@ it('opens the user menu, exposing profile and admin links', async () => { expect(screen.getByTestId('appshell-user-menu-admin-item')).toBeTruthy(); }); -it('logs out: clears the session and redirects to /login', async () => { +it('logs out: clears the session and redirects to /login?source=logged_out', async () => { + // The `?source=logged_out` query param is consumed by the login page + // onMount branch to (a) show the "Successfully signed out" banner and + // (b) skip the doomed post-logout /me + /refresh probes. A plain + // `/login` navigation regresses both — the banner disappears and the + // layout re-fires the probes on remount. See AppShell.onLogout for + // the full comment on why the query is necessary here. m(logout).mockResolvedValue(undefined); render(AppShell, { props: { children } }); await fireEvent.click(screen.getByTestId('appshell-user-menu-btn')); await fireEvent.click(await screen.findByTestId('appshell-user-menu-logout-btn')); await waitFor(() => expect(logout).toHaveBeenCalled()); - await waitFor(() => expect(goto).toHaveBeenCalledWith('/login')); + await waitFor(() => expect(goto).toHaveBeenCalledWith('/login?source=logged_out')); expect(session.user).toBeNull(); }); diff --git a/frontend/src/lib/icons/registry.ts b/frontend/src/lib/icons/registry.ts index 6e09388d..bb58f859 100644 --- a/frontend/src/lib/icons/registry.ts +++ b/frontend/src/lib/icons/registry.ts @@ -70,6 +70,17 @@ export const OxiIcons: Record = { 576, "M96 0C60.7 0 32 28.7 32 64l0 384c0 35.3 28.7 64 64 64l180 0c-10.5-14.6-19-30.7-25.1-48l-74.9 0 0-80c0-17.7 14.3-32 32-32l32 0c2 0 4 .2 5.9 .5 6-23.6 16.3-45.4 30.1-64.5l-4 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 4c27.5-19.8 60.3-32.4 96-35.4L416 64c0-35.3-28.7-64-64-64L96 0zm32 112c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM272 96l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM128 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM432 544a144 144 0 1 0 0-288 144 144 0 1 0 0 288zm22.6-144l36.7 36.7c6.2 6.2 6.2 16.4 0 22.6s-16.4 6.2-22.6 0l-36.7-36.7-36.7 36.7c-6.2 6.2-16.4 6.2-22.6 0s-6.2-16.4 0-22.6l36.7-36.7-36.7-36.7c-6.2-6.2-6.2-16.4 0-22.6s16.4-6.2 22.6 0l36.7 36.7 36.7-36.7c6.2-6.2 16.4-6.2 22.6 0s6.2 16.4 0 22.6L454.6 400z" ], + // Font Awesome Free 6.7.2 `building-shield` (office building with a + // shield overlay). Hand-added for the admin sidebar's OIDC / SSO + // entry — signals "identity provider federated to an external org" + // more directly than the generic `key` it replaced. Registry header + // says "regenerate from the source" but no generator lives in-repo, + // so hand-inserting matches the shape of every other entry (same + // precedent as `ranking-star`). + "building-shield": [ + 576, + "M0 48C0 21.5 21.5 0 48 0L336 0c26.5 0 48 21.5 48 48l0 159-42.4 17L304 224l-32 0c-8.8 0-16 7.2-16 16l0 32 0 24.2 0 7.8c0 .9 .1 1.7 .2 2.6c2.3 58.1 24.1 144.8 98.7 201.5c-5.8 2.5-12.2 3.9-18.9 3.9l-96 0 0-80c0-26.5-21.5-48-48-48s-48 21.5-48 48l0 80-96 0c-26.5 0-48-21.5-48-48L0 48zM80 224c-8.8 0-16 7.2-16 16l0 32c0 8.8 7.2 16 16 16l32 0c8.8 0 16-7.2 16-16l0-32c0-8.8-7.2-16-16-16l-32 0zm80 16l0 32c0 8.8 7.2 16 16 16l32 0c8.8 0 16-7.2 16-16l0-32c0-8.8-7.2-16-16-16l-32 0c-8.8 0-16 7.2-16 16zM64 112l0 32c0 8.8 7.2 16 16 16l32 0c8.8 0 16-7.2 16-16l0-32c0-8.8-7.2-16-16-16L80 96c-8.8 0-16 7.2-16 16zM176 96c-8.8 0-16 7.2-16 16l0 32c0 8.8 7.2 16 16 16l32 0c8.8 0 16-7.2 16-16l0-32c0-8.8-7.2-16-16-16l-32 0zm80 16l0 32c0 8.8 7.2 16 16 16l32 0c8.8 0 16-7.2 16-16l0-32c0-8.8-7.2-16-16-16l-32 0c-8.8 0-16 7.2-16 16zM423.1 225.7c5.7-2.3 12.1-2.3 17.8 0l120 48C570 277.4 576 286.2 576 296c0 63.3-25.9 168.8-134.8 214.2c-5.9 2.5-12.6 2.5-18.5 0C313.9 464.8 288 359.3 288 296c0-9.8 6-18.6 15.1-22.3l120-48zM527.4 312L432 273.8l0 187.8c68.2-33 91.5-99 95.4-149.7z" + ], "calendar": [ 512, "M120 0c13.3 0 24 10.7 24 24l0 40 160 0 0-40c0-13.3 10.7-24 24-24s24 10.7 24 24l0 40 32 0c35.3 0 64 28.7 64 64l0 288c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 128C0 92.7 28.7 64 64 64l32 0 0-40c0-13.3 10.7-24 24-24zm0 112l-56 0c-8.8 0-16 7.2-16 16l0 48 352 0 0-48c0-8.8-7.2-16-16-16l-264 0zM48 224l0 192c0 8.8 7.2 16 16 16l320 0c8.8 0 16-7.2 16-16l0-192-352 0z" diff --git a/frontend/src/lib/stores/session.svelte.test.ts b/frontend/src/lib/stores/session.svelte.test.ts index b026ddf1..20325726 100644 --- a/frontend/src/lib/stores/session.svelte.test.ts +++ b/frontend/src/lib/stores/session.svelte.test.ts @@ -8,7 +8,8 @@ const { fetchMeMock } = vi.hoisted(() => ({ fetchMeMock: vi.fn() })); vi.mock('$lib/api/endpoints/auth', () => ({ fetchMe: () => fetchMeMock(), - tryRefresh: vi.fn(async () => false) + tryRefresh: vi.fn(async () => false), + bindDpopIfPossible: vi.fn(async () => false) })); import { session } from './session.svelte'; diff --git a/frontend/src/lib/stores/session.svelte.ts b/frontend/src/lib/stores/session.svelte.ts index 8a78e6cc..bc2022c1 100644 --- a/frontend/src/lib/stores/session.svelte.ts +++ b/frontend/src/lib/stores/session.svelte.ts @@ -6,7 +6,10 @@ * 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 { setLogoutInProgress } from '$lib/api/client'; +import { hasSessionHint } from '$lib/api/csrf'; +import { seedNonceFromCookie } from '$lib/auth/dpop-proof'; import { drives } from '$lib/stores/drives.svelte'; import type { User } from '$lib/api/types'; import { ensureActiveUser } from '$lib/utils/localStoragePrefs'; @@ -40,13 +43,35 @@ class SessionStore { */ async load(): Promise { if (this.loaded) return this.user; + // No JS-visible session hint ⇒ nothing to probe. The server sets + // `oxicloud_csrf` alongside the HttpOnly session cookies and clears + // it on logout, so a missing hint means no session. Skips the + // doomed 2× /me + /refresh burst that would otherwise fire on + // every first landing / post-logout re-mount with no cookies. + if (!hasSessionHint()) { + this.user = null; + this.loaded = true; + return null; + } try { let me = await fetchMe(); 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). Gate on `is_dpop_bound` so we + // don't call the endpoint on every SPA load: password + // login already binds at session-mint time, so `/me` + // reports `true` on the very first request and skip + // avoids the 409 `already_bound` reject that would + // otherwise clutter the audit stream. Fire-and-forget + // so a slow IndexedDB open doesn't stall app boot. + if (me.is_dpop_bound === false) void bindDpopIfPossible(); + } else this.user = null; } catch { this.user = null; } @@ -65,6 +90,19 @@ class SessionStore { setUser(user: User): void { this.user = user; ensureActiveUser(user.id); + // Any successful login clears the session-teardown gate. Without + // this, a logout → login within the same SPA session leaves the + // gate stuck at `true` — the login POST is exempted via + // `AUTH_PRIMITIVES`, but the /me + /drives + … fetches the app + // fires post-login would all abort with "Session terminated". + setLogoutInProgress(false); + // Consume the one-shot `oxicloud_dpop_nonce` cookie the login + // response set. For POST logins (OPAQUE, legacy, magic-link + // SPA-side, OIDC exchange) this is where the seed lands — the + // hooks.client boot pass fires too early (before any login). + // Redirect-flow logins are seeded at boot; both paths are safe + // to double-run (idempotent, cookie is single-shot). + seedNonceFromCookie(); } /** @@ -107,6 +145,17 @@ class SessionStore { this.user = null; this.homeFolderId = null; this.homeFolderName = null; + // Mark the store as `loaded` so any subsequent `session.load()` — + // notably the login page's existing-session probe and the root + // layout's post-nav mount — short-circuits to `null` instead of + // re-probing `/api/auth/me`. After an explicit logout we know for + // a fact the session is gone; a probe would 401, the interceptor + // would retry via /refresh (also 401), and `sessionExpiredHandler` + // would divert to `/login?source=session_expired` — clobbering the + // nice "logged out" landing. On a hard nav (natural expiry path) + // module state is fresh and this flag is `false` again, so the + // probe still runs there. + this.loaded = true; } } 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/+layout.svelte b/frontend/src/routes/+layout.svelte index 0c847322..e0f6f83a 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -8,7 +8,8 @@ import AppShell from '$lib/components/AppShell.svelte'; import DialogHost from '$lib/components/DialogHost.svelte'; import Toaster from '$lib/components/Toaster.svelte'; - import { setPasswordChangeRequiredHandler } from '$lib/api/client'; + import { isLogoutInProgress, 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,8 +62,95 @@ }); 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(() => { + // A BroadcastChannel dispatches to every OTHER instance + // on the same channel — including OTHER instances in the + // SAME tab (the API only skips the exact sender instance, + // not the whole tab). So `broadcastSessionCleared()` fired + // from `logout()` on this tab re-enters here. When THIS + // tab initiated the logout, `AppShell.onLogout` has already + // navigated to `/login?source=logged_out`; running the bare + // `/login` goto below would clobber the query string (Ed's + // missing "Successfully signed out" banner). Only handle + // broadcasts from OTHER tabs. + if (isLogoutInProgress()) return; + 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(); + // 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. @@ -86,6 +174,15 @@ // protected routes. Runs client-side only (ssr=false). $effect(() => { if (!ready) return; + // During an explicit logout `AppShell.onLogout` has already picked + // the destination (`/login?source=logged_out`) and issued the + // navigation. `session.reset()` inside that flow flips + // `session.isAuthenticated` to false, which fires THIS effect + // reactively — if we don't bail, we race the pending goto with a + // `/login?redirect=` nav and last-write-wins clobbers + // the "signed out" banner (Ed's report: URL landed as + // `?redirect=%2Ffiles%2F…` instead of `?source=logged_out`). + if (isLogoutInProgress()) return; const path = page.url.pathname; if (session.isAuthenticated || isPublic(path)) return; diff --git a/frontend/src/routes/admin/[[tab]]/+page.svelte b/frontend/src/routes/admin/[[tab]]/+page.svelte index 837c631f..60633dda 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,68 @@ 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); + // Access-token TTL served alongside the sessions page — drives the + // "revoke takes effect within {N} seconds" warning. Revoke flips the + // DB row (breaks the refresh path), but a JWT already in flight + // stays valid until its `exp`. Populated on the first load, reused + // for every render — the value is server-config, not per-request. + let sessionsAccessTokenExpirySecs = $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; + sessionsAccessTokenExpirySecs = page.access_token_expiry_secs; + } catch (e) { + sessionsError = errorMessage(e); + } finally { + sessionsLoading = false; + } + } + + 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.' + ); + if (!confirm(message)) 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 +1651,7 @@ let loaded = $state>({ dashboard: false, users: false, + sessions: false, drives: false, mounts: false, plugins: false, @@ -1595,6 +1666,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(); @@ -2612,14 +2684,15 @@ {@const pct = quotaPct(u)} -
- - {u.username || u.email} - {#if isSelf(u)} - {t('admin.you_badge', 'you')} - {/if} - - {u.email} +
+ + {#if isSelf(u)} + {t('admin.you_badge', 'you')} + {/if}
@@ -2898,6 +2971,159 @@ >
{/if} + {:else if tab === 'sessions'} +
+ + + +
+ {#if sessionsError} +

{sessionsError}

+ {:else} + {#if sessionsAccessTokenExpirySecs !== null} + +

+ {t( + 'admin.sessions.revoke_lag_notice', + { secs: sessionsAccessTokenExpirySecs }, + 'Revoking a session breaks its refresh path immediately, but any JWT already in the browser stays valid for up to {{secs}} seconds until the next refresh attempt.' + )} +

+ {/if} + + + + + + + + + + + + + + + + {#each sessions as s (s.id)} + + + + + + + + + + + + {/each} + {#if sessions.length === 0 && !sessionsLoading} + + + + {/if} + +
{t('admin.sessions.col_user', 'User')}{t('admin.sessions.col_origin', 'Origin')}{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')}
+
+ + {#if s.is_current} + + {t('admin.you_badge', 'you')} + + {/if} +
+
+ + {t(`admin.sessions.origin.${s.origin}`, s.origin)} + + {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.')} +
+ {/if} {:else if tab === 'mounts'}

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

@@ -3958,6 +4184,14 @@