Merge pull request #661 from EdouardVanbelle/feat/dpop

This commit is contained in:
Dionisio Pozo
2026-08-09 19:46:30 +02:00
committed by GitHub
183 changed files with 8563 additions and 1419 deletions
+9 -1
View File
@@ -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
@@ -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"
Generated
+159 -4
View File
@@ -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",
+22
View File
@@ -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"
+9
View File
@@ -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 |
+1 -1
View File
@@ -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.
+321
View File
@@ -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 `<timestamp>_dpop_session_binding.sql`: `ALTER TABLE auth.sessions ADD COLUMN dpop_jkt VARCHAR(64)` (nullable).
- Extend `Session` domain entity: `dpop_jkt: Option<String>`.
- 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<CryptoKeyPair>` — read from IndexedDB (`db: "oxicloud-dpop"`, store: `"keypair"`), else generate P-256 with `extractable: false`, persist, return.
- `computeJkt(pubKey: CryptoKey): Promise<string>` — export public key JWK, canonicalise (RFC 7638), SHA-256, base64url — the thumbprint.
- `clearKeypair(): Promise<void>` — 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<String>`.
- 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<String>`.
- Legacy password `POST /api/auth/login`: DTO gains `dpop_jkt: Option<String>`.
- 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 = <token>` 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: <public-key-JWK>}`
- **Claims**: `{htm: <method>, htu: <canonical URL, no query>, iat: <now-seconds>, jti: <crypto.randomUUID()>, nonce: <current-nonce-or-omitted>}`
- Sign with `crypto.subtle.sign({name: "ECDSA", hash: "SHA-256"}, privateKey, payload)`.
- Set `DPoP: <compact-JWT>` 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<AppState>` 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="<invalid_dpop_proof|use_dpop_nonce>"` 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<String, NonceMeta>` (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: <fresh-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: <compact-JWT>` 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 `<img src>`, `<a href download>`, 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:
- `<img src>` — thumbnails, photo previews.
- `<a href download>` — file downloads, folder ZIP downloads.
- `<a href>` — 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=<sig>` 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 `<img src>`/`<a href>`/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 <token>` 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.
+52
View File
@@ -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
# -----------------------------------------------------------------------------
+1
View File
@@ -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,
}
}
+3
View File
@@ -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);
+9
View File
@@ -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<void> {
setSessionExpiredHandler(() => {
@@ -15,5 +16,13 @@ export async function init(): Promise<void> {
}
});
// 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();
}
+112
View File
@@ -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<typeof import('$lib/auth/dpop-proof')>('$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');
+118 -17
View File
@@ -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<Response> {
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<Response> => {
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
+17
View File
@@ -17,3 +17,20 @@ export function getCsrfHeaders(): Record<string, string> {
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='));
}
+34
View File
@@ -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<void> {
}
}
// ── 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<AdminSessionsPage> {
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<AdminSessionsPage>(`/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<void> {
return mutate(`/api/admin/sessions/${encodeURIComponent(sessionId)}`, 'DELETE');
}
// ── Users ───────────────────────────────────────────────────────────────
/** List the compact rows rendered by the management table; full account
+116 -4
View File
@@ -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<typeof import('$lib/auth/dpop-proof')>('$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<typeof vi.fn>;
const j = apiJson as unknown as ReturnType<typeof vi.fn>;
// 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);
});
});
+183 -32
View File
@@ -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<User | null> {
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<Response> => {
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<User | null> {
* 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<boolean> {
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<Response> => {
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<string | null> {
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<boolean> {
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<AuthResponse> {
// 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<void> {
}
export async function logout(): Promise<LogoutResult> {
// 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 {};
}
}
+196 -2
View File
@@ -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<typeof import('$lib/auth/dpop-proof')>('$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<typeof vi.fn>;
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<string, string>();
responseHeaders = new Map<string, string>();
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<string, string> = {}): 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<MockXHR> {
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
});
});
+109 -47
View File
@@ -62,61 +62,123 @@ export async function uploadFile(folderId: string | null, file: File): Promise<v
* Upload with progress reporting. `fetch` can't surface upload progress, so this
* uses XHR; CSRF headers are attached the same way as {@link uploadFile}.
* `onProgress` receives a fraction in [0, 1] (or NaN when length is unknown).
*
* DPoP proof is minted per attempt and attached as a `DPoP` header, mirroring
* the `apiFetch` interceptor — required for bound sessions under `required`
* mode (server 401s any state-changing call otherwise). Fresh `DPoP-Nonce`
* from the response is pushed into the shared nonce cache so the next
* request (through either apiFetch or another XHR) stays in sync. On a
* `use_dpop_nonce` challenge the upload is retried ONCE with the freshly-
* harvested nonce.
*/
export function uploadFileWithProgress(
export async function uploadFileWithProgress(
folderId: string | null,
file: File,
onProgress: (fraction: number) => void
): Promise<void> {
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<typeof setTimeout>;
const arm = (ms: number) => {
clearTimeout(watchdog);
watchdog = setTimeout(() => xhr.abort(), ms);
};
const attempt = (): Promise<void> =>
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<typeof setTimeout>;
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<void> {
@@ -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
);
+13 -2
View File
@@ -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<AuthResponse> {
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);
+57
View File
@@ -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<string, unknown>;
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;
}
+155
View File
@@ -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<string, unknown> {
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<string, string>;
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');
});
});
+197
View File
@@ -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<string | null> {
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<string, unknown> = {
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);
}
+75
View File
@@ -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));
});
});
+174
View File
@@ -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<IDBDatabase> {
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<CryptoKeyPair | null> {
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<void> {
const db = await openDb();
try {
await new Promise<void>((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<CryptoKeyPair> {
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<CryptoKeyPair> {
const doEnsure = async (): Promise<CryptoKeyPair> => {
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<string> {
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<void> {
let db: IDBDatabase;
try {
db = await openDb();
} catch {
return; // IndexedDB unavailable — nothing to clear
}
try {
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).delete(KEY);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} finally {
db.close();
}
}
@@ -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 () => {};
}
}
+24 -9
View File
@@ -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'));
}
</script>
+8 -2
View File
@@ -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();
});
+11
View File
@@ -70,6 +70,17 @@ export const OxiIcons: Record<string, IconEntry> = {
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"
@@ -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';
+52 -3
View File
@@ -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<User | null> {
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;
}
}
+80
View File
@@ -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');
});
});
+55
View File
@@ -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:<client_name>`) 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;
}
+98 -1
View File
@@ -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 — `<img src>` thumbnails, `<a href download>` 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=<current path>` 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;
+308 -8
View File
@@ -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<string | null>(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<SessionSummary[]>([]);
let sessionsError = $state<string | null>(null);
let sessionsLoading = $state(false);
let sessionsFilterUserId = $state<string>('');
let sessionsIncludeRevoked = $state(false);
let sessionRevokingId = $state<string | null>(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<number | null>(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<PluginInfo[]>([]);
let pluginsAvailable = $state(true);
@@ -1581,6 +1651,7 @@
let loaded = $state<Record<Tab, boolean>>({
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)}
<tr>
<td>
<div class="user-cell">
<strong>
{u.username || u.email}
{#if isSelf(u)}
<span class="badge badge--self">{t('admin.you_badge', 'you')}</span>
{/if}
</strong>
<span class="muted">{u.email}</span>
<div class="user-vignette-cell">
<UserVignette
userId={u.id}
fallbackLabel={u.username || u.email}
fallbackSublabel={u.email}
/>
{#if isSelf(u)}
<span class="badge badge--self">{t('admin.you_badge', 'you')}</span>
{/if}
</div>
</td>
<td>
@@ -2898,6 +2971,159 @@
>
</div>
{/if}
{:else if tab === 'sessions'}
<div class="bar">
<label class="bar__filter">
{t('admin.sessions.filter_user', 'User (UUID)')}
<input
type="text"
placeholder="00000000-…"
data-testid="admin-sessions-user-filter-input"
bind:value={sessionsFilterUserId}
/>
</label>
<label class="bar__toggle">
<input
type="checkbox"
data-testid="admin-sessions-include-revoked-checkbox"
bind:checked={sessionsIncludeRevoked}
/>
{t('admin.sessions.include_revoked', 'Include revoked / expired')}
</label>
<button
class="btn"
data-testid="admin-sessions-refresh-btn"
onclick={() => void loadSessions()}
disabled={sessionsLoading}
>
<Icon name="sync-alt" />
{sessionsLoading ? t('common.loading', 'Loading…') : t('admin.sessions.refresh', 'Refresh')}
</button>
</div>
{#if sessionsError}
<p class="status status--error" data-testid="admin-sessions-error">{sessionsError}</p>
{:else}
{#if sessionsAccessTokenExpirySecs !== null}
<!-- Revoke-lag notice: revoke breaks the refresh path
immediately, but a JWT already in flight stays valid
until its `exp` (see docs/plan/dpop.md — access tokens
are opaque to revocation between refreshes). Server
publishes the current TTL so this text is honest
rather than a hardcoded guess. -->
<p class="status status--info" role="note" data-testid="admin-sessions-revoke-lag-notice">
{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.'
)}
</p>
{/if}
<table class="table" data-testid="admin-sessions-table">
<thead>
<tr>
<th>{t('admin.sessions.col_user', 'User')}</th>
<th>{t('admin.sessions.col_origin', 'Origin')}</th>
<th>{t('admin.sessions.col_created', 'Created')}</th>
<th>{t('admin.sessions.col_expires', 'Expires')}</th>
<th>{t('admin.sessions.col_ip', 'IP')}</th>
<th>{t('admin.sessions.col_user_agent', 'User agent')}</th>
<th>{t('admin.sessions.col_bound', 'Bound')}</th>
<th>{t('admin.sessions.col_status', 'Status')}</th>
<th></th>
</tr>
</thead>
<tbody>
{#each sessions as s (s.id)}
<tr
data-testid={`admin-sessions-row-${s.id}`}
class:muted={!s.is_active}
class:current-session={s.is_current}
>
<td>
<div class="user-vignette-cell">
<UserVignette userId={s.user_id} fallbackLabel={s.user_id} />
{#if s.is_current}
<span
class="badge badge--self"
title={t(
'admin.sessions.current_tooltip',
"This is the session you're using right now — revoking it will log you out."
)}
>
{t('admin.you_badge', 'you')}
</span>
{/if}
</div>
</td>
<td data-testid={`admin-sessions-origin-${s.id}`}>
<span class="badge badge--origin badge--origin-{s.origin}">
{t(`admin.sessions.origin.${s.origin}`, s.origin)}
</span>
</td>
<td>{new Date(s.created_at).toLocaleString()}</td>
<td>{new Date(s.expires_at).toLocaleString()}</td>
<td class="mono">{s.ip_address ?? '—'}</td>
<td class="truncate" title={s.user_agent ?? ''}>
{shortUserAgent(s.user_agent)}
</td>
<td>
{#if s.is_bound}
<span
class="bound-cell"
title={t(
'admin.sessions.bound_tooltip',
{ prefix: s.dpop_jkt_prefix ?? '' },
'DPoP-bound (jkt {{prefix}}…)'
)}
>
<Icon name="lock" />
<span class="mono">{s.dpop_jkt_prefix ?? ''}</span>
</span>
{:else}
<span class="muted">{t('admin.sessions.unbound', 'unbound')}</span>
{/if}
</td>
<td>
{#if s.is_revoked}
<span class="badge badge--inactive">
{t('admin.sessions.revoked', 'revoked')}
</span>
{:else if !s.is_active}
<span class="badge badge--inactive">
{t('admin.sessions.expired', 'expired')}
</span>
{:else}
<span class="badge badge--active">
{t('admin.sessions.active', 'active')}
</span>
{/if}
</td>
<td>
{#if !s.is_revoked}
<button
class="icon-btn icon-btn--danger"
data-testid={`admin-sessions-revoke-btn-${s.id}`}
title={t('admin.sessions.revoke', 'Revoke')}
aria-label={t('admin.sessions.revoke', 'Revoke')}
onclick={() => void onRevokeSession(s.id, s.is_current)}
disabled={sessionRevokingId === s.id}
>
<Icon name="trash-alt" />
</button>
{/if}
</td>
</tr>
{/each}
{#if sessions.length === 0 && !sessionsLoading}
<tr>
<td colspan="9" class="muted">
{t('admin.sessions.empty', 'No sessions match the current filter.')}
</td>
</tr>
{/if}
</tbody>
</table>
{/if}
{:else if tab === 'mounts'}
<section class="admin-section" data-testid="admin-mounts-section">
<h2>{t('admin.mounts.title', 'External File Mounts')}</h2>
@@ -3958,6 +4184,14 @@
</Modal>
<style>
/* Admin sessions panel — accent the caller's own row so revoking
it can't happen by muscle memory. Left-border stripe matches how
Users' table calls out the caller via the `you` badge; JS
confirms with an escalated message on top of the visual cue. */
.current-session td:first-child {
border-left: 3px solid var(--color-accent);
}
.logs-toolbar {
display: flex;
gap: var(--space-2);
@@ -4911,6 +5145,60 @@
.bar {
display: flex;
justify-content: flex-end;
align-items: center;
gap: var(--space-3, 0.75rem);
flex-wrap: wrap;
}
/* Sessions-panel toolbar items — filter input + include-revoked
checkbox pushed to the left, refresh button anchored right. */
.bar__filter {
display: inline-flex;
align-items: center;
gap: var(--space-2, 0.5rem);
margin-right: auto;
font-size: 0.875rem;
}
.bar__filter input {
padding: 0.375rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md, 4px);
background: var(--color-bg-input, var(--color-bg));
color: var(--color-text);
font-family: var(--font-mono, monospace);
font-size: 0.8125rem;
min-width: 20ch;
}
.bar__toggle {
display: inline-flex;
align-items: center;
gap: var(--space-2, 0.5rem);
font-size: 0.875rem;
cursor: pointer;
}
/* Admin table user cell — UserVignette (avatar + name + email)
with the "you" badge parked to its right when this row belongs
to the caller. Flex + gap keeps them shoulder-to-shoulder
without collapsing on narrow columns. Shared across Users +
Sessions tabs; both benefit from the same avatar/name/email
presentation. */
.user-vignette-cell {
display: flex;
align-items: center;
gap: var(--space-2, 0.5rem);
flex-wrap: wrap;
}
/* DPoP-bound cell: lock icon + short jkt prefix, kept tight so
the column doesn't inflate on wide viewports. */
.bound-cell {
display: inline-flex;
align-items: center;
gap: var(--space-1, 0.25rem);
font-size: 0.8125rem;
}
.table {
@@ -4926,6 +5214,18 @@
font-size: 0.875rem;
}
/* Row hover highlight — covers Users / Sessions / Drives (every
admin table renders through `.table`). Header rows and empty-
state rows are excluded via `tbody` scoping. `transition` keeps
the tint from feeling twitchy on fast pointer movement. */
.table tbody tr {
transition: background-color 120ms ease;
}
.table tbody tr:hover {
background-color: var(--color-bg-hover);
}
.user-cell {
display: flex;
flex-direction: column;
+79 -10
View File
@@ -9,6 +9,7 @@
import { onMount } from 'svelte';
import { ApiError } from '$lib/api/client';
import {
bindDpopIfPossible,
exchangeOidcCode,
fetchMe,
getAuthStatus,
@@ -21,6 +22,7 @@
} from '$lib/api/endpoints/auth';
import { i18n, SUPPORTED_LOCALES, setLocale, t, type Locale } from '$lib/i18n/index.svelte';
import { session } from '$lib/stores/session.svelte';
import { hasSessionHint } from '$lib/api/csrf';
type Mode = 'login' | 'register' | 'setup';
let mode = $state<Mode>('login');
@@ -97,6 +99,16 @@
// immediately after so revisits / manual logouts don't re-show
// the stale message.
let sessionExpiredNotice = $state(false);
// One-shot "logged out" success banner, distinct from the
// session-expired one above. Triggered by AppShell::onLogout via
// `?source=logged_out`. Consumed on mount (URL stripped) so the
// notice never re-appears on reload.
let loggedOutNotice = $state(false);
// Also gates the existing-session probe below — after an explicit
// logout we know the session is dead; probing would 401 → refresh
// → 401 and clobber this landing with `?source=session_expired`
// via the interceptor.
let skipExistingSessionProbe = $state(false);
// One-shot notice populated from ?login_error=<key> on mount.
// Set by the OIDC callback's AutoLinkRefused redirect when the
// IdP-returned email matches an existing local account but the
@@ -344,8 +356,13 @@
// Strip it from the URL so the banner never re-appears on
// reloads / manual logout redirects. Uses history.replaceState
// (no navigation, no scroll jump).
if (page.url.searchParams.get('source') === 'session_expired') {
sessionExpiredNotice = true;
const sourceParam = page.url.searchParams.get('source');
if (sourceParam === 'session_expired' || sourceParam === 'logged_out') {
if (sourceParam === 'session_expired') sessionExpiredNotice = true;
else loggedOutNotice = true;
// Either flag means we KNOW there's no live session — skip
// the existing-session probe further down.
skipExistingSessionProbe = true;
const stripped = new URL(page.url);
stripped.searchParams.delete('source');
window.history.replaceState(
@@ -378,6 +395,14 @@
const user = await exchangeOidcCode(oidcCode);
if (user) {
session.setUser(user);
// OIDC callback creates the session UNBOUND (redirect flow can't
// carry a JKT in the callback body). Post-redirect bind here
// attaches the browser keypair — one-shot, idempotent (server
// returns 409 if already bound). Awaited so subsequent requests
// under `DPOP=required` land with a bound session, not an
// unbound one that would 401 on the very next thumbnail. See
// `docs/plan/dpop.md` Gate 3.
await bindDpopIfPossible();
await goto(resolve(redirectTarget), { replaceState: true });
return;
}
@@ -385,15 +410,23 @@
}
// 2) Existing-session probe: if already authenticated, skip the form.
try {
const me = await fetchMe();
if (me) {
session.setUser(me);
await goto(resolve(redirectTarget), { replaceState: true });
return;
// Skipped when we KNOW the session is gone: explicit logout /
// interceptor-detected expiry (both set `skipExistingSessionProbe`),
// OR the CSRF hint cookie is absent (fresh browser, no cookies at
// all — a probe would just 401). Probing anyway would trip the
// apiFetch → 401 → refresh → 401 → sessionExpiredHandler chain
// that clobbers whatever notice we're about to paint.
if (!skipExistingSessionProbe && hasSessionHint()) {
try {
const me = await fetchMe();
if (me) {
session.setUser(me);
await goto(resolve(redirectTarget), { replaceState: true });
return;
}
} catch {
/* probe failed — show the login page */
}
} catch {
/* probe failed — show the login page */
}
// 3) Bootstrap probe: a fresh install (no admin) must be set up first.
@@ -415,8 +448,26 @@
// avoids stealing focus from something else during the loading
// splash; the input-ref guard covers the render-order case where
// the effect fires before the DOM has the target.
//
// `activeElement` guard: if the user (or Playwright's `.fill()`, or
// browser autofill) already has focus in a form field, don't yank
// it away. Concrete bug this prevents: boot probes are slow → user
// types their email into the (initially unfocused) input → probes
// finish → `booting` flips false → this effect fires and refocuses
// the input, which resets the caret and can concatenate subsequent
// keystrokes onto the wrong field if the user was mid-tab. Mode-
// swap re-runs still refocus correctly because the old form's
// inputs unmount first, resetting `activeElement` to `<body>`.
$effect(() => {
if (booting) return;
const active = document.activeElement;
if (
active &&
active !== document.body &&
(active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')
) {
return;
}
const target =
mode === 'login'
? loginIdentifierInput
@@ -500,6 +551,24 @@
</div>
{/if}
{#if loggedOutNotice}
<div
class="auth-success auth-error--dismissible"
style="display: flex"
role="status"
data-testid="login-logged-out-notice"
>
<span>{t('auth.logged_out', 'Successfully signed out.')}</span>
<button
type="button"
class="auth-notice-dismiss"
aria-label={t('common.dismiss', 'Dismiss')}
data-testid="login-logged-out-dismiss-btn"
onclick={() => (loggedOutNotice = false)}>×</button
>
</div>
{/if}
{#if postRegisterNotice && mode === 'login'}
<div
class="auth-success auth-error--dismissible"
+10
View File
@@ -21,6 +21,7 @@ vi.mock('$app/navigation', () => ({ goto }));
vi.mock('$app/state', () => ({ page: pageState }));
vi.mock('$lib/stores/session.svelte', () => ({ session }));
vi.mock('$lib/api/endpoints/auth', () => ({
bindDpopIfPossible: vi.fn().mockResolvedValue(false),
exchangeOidcCode: vi.fn(),
fetchMe: vi.fn(),
getOidcProviders: vi.fn(),
@@ -90,9 +91,18 @@ it('exchanges an oidc code on mount and redirects', async () => {
});
it('skips the form when already authenticated', async () => {
// The existing-session probe is gated on `hasSessionHint()` — no
// `oxicloud_csrf` cookie ⇒ probe is skipped and no `fetchMe` fires
// (see login/+page.svelte step 2 for the rationale). Plant the
// cookie the backend would have set on a live session so the
// probe path is exercised end-to-end here.
document.cookie = 'oxicloud_csrf=test-token; Path=/';
m(auth.fetchMe).mockResolvedValue({ id: '1' });
render(LoginPage);
await waitFor(() => expect(goto).toHaveBeenCalled());
// Clean up so the following tests don't inherit the hint and
// unexpectedly probe on their own boot.
document.cookie = 'oxicloud_csrf=; Path=/; Max-Age=0';
});
it('enters setup mode on a fresh install', async () => {
+129
View File
@@ -0,0 +1,129 @@
/// <reference types="@sveltejs/kit" />
/// <reference lib="webworker" />
/**
* DPoP-signing Service Worker (RFC 9449 companion to the page-context
* `apiFetch` interceptor).
*
* WHY this exists. Under `OXICLOUD_DPOP_MODE=required` the server rejects
* bound-session requests that carry no DPoP proof. The page-context
* `apiFetch` interceptor covers everything the SPA drives through
* `fetch()` — but the browser itself makes requests JS can never touch:
* `<img src>` (thumbnails, photo previews), `<a href download>` /
* `<a href>` (file downloads / inline previews), and `EventSource`
* (admin log tail). Those had lived behind a middleware allowlist
* (Gate C) — this SW replaces that allowlist entirely by attaching a
* proof to every same-origin `/api/*` request the browser makes,
* regardless of who initiated it.
*
* Why NOT a Web Worker. Dedicated workers can't see network requests
* the page initiates. Only Service Workers register a `fetch` handler
* for their scope. This IS the browser-side hook for browser-driven
* requests.
*
* Shared state with the page. Both scopes share the same-origin
* IndexedDB (where the persistent P-256 keypair lives) and SubtleCrypto
* (also available in SW context). The nonce cache is per-scope — the
* page module holds its own in-memory nonce, the SW holds its own; on
* first request each scope pays a one-round-trip nonce challenge, then
* catches up via `DPoP-Nonce` response headers.
*
* Skip conditions:
* * cross-origin (privacy — never leak the user's keypair thumbprint
* to third parties);
* * anything outside `/api/*` (static assets don't hit the DPoP
* middleware, no need to burn crypto per request);
* * requests that already carry a `DPoP` header — the page context
* signed them via `apiFetch` (or the XHR upload path), don't
* double-sign;
* * proof unavailable (keypair inaccessible) — pass through
* unsigned so unbound sessions still function (fail-open contract,
* matches `docs/plan/dpop.md`).
*/
import {
buildDpopProof,
isDpopNonceChallenge,
updateNonceFromResponse
} from '$lib/auth/dpop-proof';
// `self` is typed as `ServiceWorkerGlobalScope` via the
// `/// <reference lib="webworker" />` directive above — no explicit
// `declare const self` needed under the SvelteKit build context.
const ORIGIN = self.location.origin;
// Fast-forward the SW lifecycle so open tabs pick up the new version
// on the next navigation without waiting for every existing tab to
// close (default lifecycle stalls activation until then).
self.addEventListener('install', () => {
void self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});
self.addEventListener('fetch', (event) => {
const req = event.request;
const url = new URL(req.url);
// Same-origin only — never leak a DPoP proof (which carries the
// user's public-key JWK) to a third-party host.
if (url.origin !== ORIGIN) return;
// Only DPoP-protected paths need signing. Static assets, locales,
// vendors are outside the middleware and don't need the crypto tax.
if (!url.pathname.startsWith('/api/')) return;
// Don't double-sign — page-context `apiFetch`, `fetchMe`, `tryRefresh`,
// and `uploadFileWithProgress` already attach a proof themselves.
if (req.headers.has('DPoP')) return;
event.respondWith(signAndFetch(req));
});
async function signAndFetch(req: Request): Promise<Response> {
const firstProof = await buildDpopProof(req.method, req.url).catch(() => null);
// No keypair (IndexedDB blocked, SubtleCrypto missing, etc.) — pass
// through unsigned. Unbound sessions still work; bound sessions in
// `required` mode will 401, matching the fail-open contract.
if (!firstProof) return fetch(req);
// Body-preservation contract: `new Request(existing, ...)` transfers
// ownership of `existing.body` (a `ReadableStream` — read once). To
// keep a retry option open for POST / PUT bodies we tee ahead of the
// first attempt via `req.clone()`. Cheap on GET (no body), one
// stream tee on state-changing calls.
const retryReq = req.clone();
const first = await fetch(signWith(req, firstProof));
updateNonceFromResponse(first);
if (!isDpopNonceChallenge(first)) return first;
// Fresh proof — the current call to `buildDpopProof` picks up the
// nonce we just harvested from `first`'s `DPoP-Nonce` header.
const secondProof = await buildDpopProof(retryReq.method, retryReq.url).catch(() => null);
if (!secondProof) return first; // couldn't sign — surface the challenge
return fetch(signWith(retryReq, secondProof));
}
/**
* Build the signed outbound `Request` from an intercepted one.
*
* `mode: 'same-origin'` is load-bearing. Browser-initiated `<img src>`
* / `<a href>` requests default to `mode: 'no-cors'`, and in no-cors
* mode the browser silently strips any header not on the CORS-safelist
* (`Accept`, `Accept-Language`, `Content-Language`, `Content-Type`)
* BEFORE sending — so `DPoP` would never reach the wire even though
* `Headers.set('DPoP', …)` succeeds in JS. `same-origin` (or `cors`)
* lets custom headers through. Legal for our targets: every path we
* intercept starts with `/api/` on the same origin as the SW itself.
*/
function signWith(req: Request, proof: string): Request {
return new Request(req, {
headers: withDpopHeader(req.headers, proof),
mode: 'same-origin'
});
}
function withDpopHeader(existing: Headers, proof: string): Headers {
const h = new Headers(existing);
h.set('DPoP', proof);
return h;
}
+35 -2
View File
@@ -564,7 +564,8 @@
"magic_hint": "ليس لديك كلمة مرور؟ أدخل بريدك الإلكتروني وسنرسل لك رابط تسجيل دخول لمرة واحدة.",
"magic_unavailable": "تسجيل الدخول عبر البريد الإلكتروني غير متاح على هذا الخادم.",
"passwords_match": "Passwords match",
"sign_in": "تسجيل الدخول"
"sign_in": "تسجيل الدخول",
"logged_out": "تم تسجيل الخروج بنجاح."
},
"storage": {
"title": "التخزين",
@@ -842,6 +843,38 @@
"quotas": "الحصص",
"reset_pw_for": "كلمة مرور جديدة لـ",
"role": "الدور",
"sessions": {
"filter_user": "المستخدم (UUID)",
"include_revoked": "تضمين الملغاة / المنتهية",
"refresh": "تحديث",
"col_user": "المستخدم",
"col_origin": "المصدر",
"col_created": "تم الإنشاء",
"col_expires": "تنتهي في",
"col_ip": "IP",
"col_user_agent": "وكيل المستخدم",
"col_bound": "مرتبطة",
"col_status": "الحالة",
"current_tooltip": "هذه هي الجلسة التي تستخدمها حاليًا — إلغاؤها سيؤدي إلى تسجيل خروجك.",
"bound_tooltip": "مرتبطة بـ DPoP (jkt {{prefix}}…)",
"unbound": "غير مرتبطة",
"revoked": "ملغاة",
"expired": "منتهية",
"active": "نشطة",
"revoke": "إلغاء",
"empty": "لا توجد جلسات مطابقة للتصفية الحالية.",
"revoke_self_confirm": "⚠️ هذه جلستك الحالية. إلغاؤها سيؤدي إلى تسجيل خروجك فورًا وعليك تسجيل الدخول مرة أخرى. هل تريد المتابعة؟",
"revoke_confirm": "إلغاء هذه الجلسة؟ سيتلقى المتصفح رمز 401 عند الطلب التالي.",
"revoke_lag_notice": "إلغاء الجلسة يقطع مسار التحديث فورًا، لكن أي JWT موجود بالفعل في المتصفح يظل صالحًا حتى {{secs}} ثانية حتى محاولة التحديث التالية.",
"origin": {
"password": "كلمة المرور",
"opaque": "OPAQUE",
"magic_link": "رابط سحري",
"oidc": "SSO",
"device": "جهاز",
"unknown": "غير معروف"
}
},
"smtp_fail": "فشل الإرسال.",
"smtp_send": "إرسال",
"smtp_test": "إرسال بريد اختباري",
@@ -1341,4 +1374,4 @@
"wrong_drop_zone_msg": "الرفع يعمل فقط في قسم الملفات — افتح قسم الملفات وأفلت العناصر هناك.",
"wrong_drop_zone_action": "انتقل إلى الملفات"
}
}
}
+35 -2
View File
@@ -564,7 +564,8 @@
"magic_hint": "Kein Passwort? Geben Sie Ihre E-Mail-Adresse ein und wir senden Ihnen einen einmaligen Anmeldelink.",
"magic_unavailable": "Die Anmeldung per E-Mail ist auf diesem Server nicht verfügbar.",
"passwords_match": "Passwörter stimmen überein",
"sign_in": "Anmelden"
"sign_in": "Anmelden",
"logged_out": "Erfolgreich abgemeldet."
},
"storage": {
"title": "Speicher",
@@ -842,6 +843,38 @@
"quotas": "Kontingente",
"reset_pw_for": "Neues Passwort für",
"role": "Rolle",
"sessions": {
"filter_user": "Benutzer (UUID)",
"include_revoked": "Widerrufene / abgelaufene einschließen",
"refresh": "Aktualisieren",
"col_user": "Benutzer",
"col_origin": "Herkunft",
"col_created": "Erstellt",
"col_expires": "Läuft ab",
"col_ip": "IP",
"col_user_agent": "User Agent",
"col_bound": "Gebunden",
"col_status": "Status",
"current_tooltip": "Dies ist die Sitzung, die Sie gerade nutzen — der Widerruf meldet Sie ab.",
"bound_tooltip": "DPoP-gebunden (jkt {{prefix}}…)",
"unbound": "ungebunden",
"revoked": "widerrufen",
"expired": "abgelaufen",
"active": "aktiv",
"revoke": "Widerrufen",
"empty": "Keine Sitzungen entsprechen dem aktuellen Filter.",
"revoke_self_confirm": "⚠️ Dies ist IHRE aktuelle Sitzung. Wenn Sie sie widerrufen, werden Sie sofort abgemeldet und müssen sich neu anmelden. Fortfahren?",
"revoke_confirm": "Diese Sitzung widerrufen? Die nächste Anfrage von diesem Browser erhält 401.",
"revoke_lag_notice": "Das Widerrufen einer Sitzung unterbricht den Refresh-Pfad sofort, aber ein bereits im Browser vorhandenes JWT bleibt bis zu {{secs}} Sekunden gültig, bis der nächste Refresh-Versuch erfolgt.",
"origin": {
"password": "Passwort",
"opaque": "OPAQUE",
"magic_link": "Magic Link",
"oidc": "SSO",
"device": "Gerät",
"unknown": "Unbekannt"
}
},
"smtp_fail": "Senden fehlgeschlagen.",
"smtp_send": "Senden",
"smtp_test": "Test-E-Mail senden",
@@ -1341,4 +1374,4 @@
"wrong_drop_zone_msg": "Uploads funktionieren nur in Dateien — öffne den Bereich Dateien und lege die Elemente dort ab.",
"wrong_drop_zone_action": "Zu Dateien wechseln"
}
}
}
+33
View File
@@ -724,6 +724,7 @@
"passwords_match": "Passwords match",
"register_error": "Registration failed",
"session_expired": "Your session expired. Please sign in again.",
"logged_out": "Successfully signed out.",
"sign_in": "Sign in",
"signing_in": "Signing in…",
"sending": "Sending…",
@@ -1114,6 +1115,38 @@
"registration_disabled_warning": "Public registration is disabled. Only admins can create new accounts.",
"reset_pw_for": "New password for",
"role": "Role",
"sessions": {
"filter_user": "User (UUID)",
"include_revoked": "Include revoked / expired",
"refresh": "Refresh",
"col_user": "User",
"col_origin": "Origin",
"col_created": "Created",
"col_expires": "Expires",
"col_ip": "IP",
"col_user_agent": "User agent",
"col_bound": "Bound",
"col_status": "Status",
"current_tooltip": "This is the session you're using right now — revoking it will log you out.",
"bound_tooltip": "DPoP-bound (jkt {{prefix}}…)",
"unbound": "unbound",
"revoked": "revoked",
"expired": "expired",
"active": "active",
"revoke": "Revoke",
"empty": "No sessions match the current filter.",
"revoke_self_confirm": "⚠️ This is YOUR current session. Revoking it will log YOU out immediately and you'll have to sign back in. Continue?",
"revoke_confirm": "Revoke this session? The next request from that browser will 401.",
"revoke_lag_notice": "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.",
"origin": {
"password": "Password",
"opaque": "OPAQUE",
"magic_link": "Magic link",
"oidc": "SSO",
"device": "Device",
"unknown": "Unknown"
}
},
"settings_saved_ok": "Settings saved.",
"smtp": "Email (SMTP)",
"smtp_fail": "Send failed.",
+35 -2
View File
@@ -569,7 +569,8 @@
"magic_hint": "¿Sin contraseña? Introduce tu correo electrónico y te enviaremos un enlace de inicio de sesión único.",
"magic_unavailable": "El inicio de sesión por correo electrónico no está disponible en este servidor.",
"passwords_match": "Las contraseñas coinciden",
"sign_in": "Iniciar sesión"
"sign_in": "Iniciar sesión",
"logged_out": "Sesión cerrada correctamente."
},
"storage": {
"title": "Almacenamiento",
@@ -847,6 +848,38 @@
"quotas": "Cuotas",
"reset_pw_for": "Nueva contraseña para",
"role": "Rol",
"sessions": {
"filter_user": "Usuario (UUID)",
"include_revoked": "Incluir revocadas / caducadas",
"refresh": "Actualizar",
"col_user": "Usuario",
"col_origin": "Origen",
"col_created": "Creada",
"col_expires": "Caduca",
"col_ip": "IP",
"col_user_agent": "Agente de usuario",
"col_bound": "Vinculada",
"col_status": "Estado",
"current_tooltip": "Esta es la sesión que está usando ahora mismo — al revocarla se cerrará su sesión.",
"bound_tooltip": "Vinculada con DPoP (jkt {{prefix}}…)",
"unbound": "no vinculada",
"revoked": "revocada",
"expired": "caducada",
"active": "activa",
"revoke": "Revocar",
"empty": "Ninguna sesión coincide con el filtro actual.",
"revoke_self_confirm": "⚠️ Esta es SU sesión actual. Al revocarla se cerrará su sesión inmediatamente y tendrá que iniciar sesión de nuevo. ¿Continuar?",
"revoke_confirm": "¿Revocar esta sesión? La próxima petición desde ese navegador devolverá 401.",
"revoke_lag_notice": "Revocar una sesión rompe su vía de renovación de inmediato, pero cualquier JWT ya presente en el navegador permanece válido hasta {{secs}} segundos, hasta el próximo intento de renovación.",
"origin": {
"password": "Contraseña",
"opaque": "OPAQUE",
"magic_link": "Enlace mágico",
"oidc": "SSO",
"device": "Dispositivo",
"unknown": "Desconocido"
}
},
"smtp_fail": "Fallo al enviar.",
"smtp_send": "Enviar",
"smtp_test": "Enviar correo de prueba",
@@ -1356,4 +1389,4 @@
"wrong_drop_zone_msg": "Las subidas solo funcionan en Archivos — abre la sección Archivos y suelta ahí los elementos.",
"wrong_drop_zone_action": "Ir a Archivos"
}
}
}
+35 -2
View File
@@ -564,7 +564,8 @@
"magic_hint": "رمز عبور ندارید؟ ایمیل خود را وارد کنید تا یک پیوند ورود یک‌بار‌مصرف برایتان ارسال شود.",
"magic_unavailable": "ورود با ایمیل در این سرور در دسترس نیست.",
"passwords_match": "Passwords match",
"sign_in": "ورود"
"sign_in": "ورود",
"logged_out": "با موفقیت خارج شدید."
},
"storage": {
"title": "فضای ذخیره‌سازی",
@@ -825,6 +826,38 @@
"quotas": "سهمیه‌ها",
"reset_pw_for": "رمز جدید برای",
"role": "نقش",
"sessions": {
"filter_user": "کاربر (UUID)",
"include_revoked": "شامل لغو‌شده‌ها / منقضی‌شده‌ها",
"refresh": "به‌روزرسانی",
"col_user": "کاربر",
"col_origin": "منبع",
"col_created": "ایجاد شده",
"col_expires": "انقضا",
"col_ip": "IP",
"col_user_agent": "عامل کاربر",
"col_bound": "متصل",
"col_status": "وضعیت",
"current_tooltip": "این همان نشستی است که هم‌اکنون از آن استفاده می‌کنید — لغو آن شما را خارج می‌کند.",
"bound_tooltip": "متصل به DPoP (jkt {{prefix}}…)",
"unbound": "متصل نیست",
"revoked": "لغو شده",
"expired": "منقضی",
"active": "فعال",
"revoke": "لغو",
"empty": "هیچ نشستی با فیلتر فعلی مطابقت ندارد.",
"revoke_self_confirm": "⚠️ این نشست فعلی شماست. با لغو آن، بلافاصله خارج می‌شوید و باید دوباره وارد شوید. ادامه؟",
"revoke_confirm": "این نشست لغو شود؟ درخواست بعدی از آن مرورگر با 401 پاسخ داده می‌شود.",
"revoke_lag_notice": "لغو یک نشست بلافاصله مسیر تازه‌سازی را قطع می‌کند، اما هر JWT که از قبل در مرورگر وجود دارد تا {{secs}} ثانیه تا تلاش بعدی برای تازه‌سازی معتبر باقی می‌ماند.",
"origin": {
"password": "رمز عبور",
"opaque": "OPAQUE",
"magic_link": "پیوند جادویی",
"oidc": "SSO",
"device": "دستگاه",
"unknown": "ناشناخته"
}
},
"smtp_fail": "ارسال ناموفق.",
"smtp_send": "ارسال",
"smtp_test": "ارسال ایمیل آزمایشی",
@@ -1341,4 +1374,4 @@
"wrong_drop_zone_msg": "بارگذاری فقط در بخش پرونده‌ها کار می‌کند — بخش پرونده‌ها را باز کنید و آنجا رها کنید.",
"wrong_drop_zone_action": "برو به پرونده‌ها"
}
}
}
+35 -2
View File
@@ -572,7 +572,8 @@
"login_error_already_linked_elsewhere": "Un compte local avec cette adresse e-mail existe déjà et est relié à une autre identité SSO. Contactez votre administrateur.",
"login_error_callback_denied": "Votre lien de connexion a expiré ou a déjà été utilisé. Veuillez réessayer.",
"login_error_callback_failed": "La connexion SSO n'a pas pu se terminer. Veuillez réessayer.",
"login_error_generic": "La connexion SSO a été refusée. Veuillez réessayer."
"login_error_generic": "La connexion SSO a été refusée. Veuillez réessayer.",
"logged_out": "Vous êtes déconnecté."
},
"storage": {
"title": "Stockage",
@@ -853,6 +854,38 @@
"quotas": "Quotas",
"reset_pw_for": "Nouveau mot de passe pour",
"role": "Rôle",
"sessions": {
"filter_user": "Utilisateur (UUID)",
"include_revoked": "Inclure les révoquées / expirées",
"refresh": "Actualiser",
"col_user": "Utilisateur",
"col_origin": "Origine",
"col_created": "Créée",
"col_expires": "Expire",
"col_ip": "IP",
"col_user_agent": "Agent utilisateur",
"col_bound": "Liée",
"col_status": "Statut",
"current_tooltip": "Il s'agit de la session que vous utilisez actuellement — la révoquer vous déconnectera.",
"bound_tooltip": "Liée par DPoP (jkt {{prefix}}…)",
"unbound": "non liée",
"revoked": "révoquée",
"expired": "expirée",
"active": "active",
"revoke": "Révoquer",
"empty": "Aucune session ne correspond au filtre actuel.",
"revoke_self_confirm": "⚠️ Il s'agit de VOTRE session actuelle. La révoquer vous déconnectera immédiatement et vous devrez vous reconnecter. Continuer ?",
"revoke_confirm": "Révoquer cette session ? La prochaine requête depuis ce navigateur renverra 401.",
"revoke_lag_notice": "La révocation d'une session coupe immédiatement son chemin de rafraîchissement, mais tout JWT déjà présent dans le navigateur reste valide pendant jusqu'à {{secs}} secondes, jusqu'à la prochaine tentative de rafraîchissement.",
"origin": {
"password": "Mot de passe",
"opaque": "OPAQUE",
"magic_link": "Lien magique",
"oidc": "SSO",
"device": "Appareil",
"unknown": "Inconnu"
}
},
"smtp_fail": "Échec de l'envoi.",
"smtp_send": "Envoyer",
"smtp_test": "Envoyer l'e-mail de test",
@@ -1363,4 +1396,4 @@
"wrong_drop_zone_msg": "Les envois ne fonctionnent que dans Fichiers — ouvrez la section Fichiers et déposez-y vos éléments.",
"wrong_drop_zone_action": "Aller aux Fichiers"
}
}
}
+35 -2
View File
@@ -564,7 +564,8 @@
"magic_hint": "पासवर्ड नहीं है? अपना ईमेल दर्ज करें और हम आपको एक बार उपयोग होने वाला साइन-इन लिंक भेज देंगे।",
"magic_unavailable": "इस सर्वर पर ईमेल द्वारा साइन-इन उपलब्ध नहीं है।",
"passwords_match": "Passwords match",
"sign_in": "साइन इन"
"sign_in": "साइन इन",
"logged_out": "सफलतापूर्वक साइन आउट हो गए।"
},
"storage": {
"title": "स्टोरेज",
@@ -842,6 +843,38 @@
"quotas": "कोटा",
"reset_pw_for": "नया पासवर्ड",
"role": "भूमिका",
"sessions": {
"filter_user": "उपयोगकर्ता (UUID)",
"include_revoked": "रद्द / समाप्त शामिल करें",
"refresh": "ताज़ा करें",
"col_user": "उपयोगकर्ता",
"col_origin": "स्रोत",
"col_created": "बनाई गई",
"col_expires": "समाप्त होगी",
"col_ip": "IP",
"col_user_agent": "उपयोगकर्ता एजेंट",
"col_bound": "बद्ध",
"col_status": "स्थिति",
"current_tooltip": "यह वह सत्र है जिसे आप अभी उपयोग कर रहे हैं — इसे रद्द करने पर आप साइन आउट हो जाएंगे।",
"bound_tooltip": "DPoP से बद्ध (jkt {{prefix}}…)",
"unbound": "अबद्ध",
"revoked": "रद्द की गई",
"expired": "समाप्त",
"active": "सक्रिय",
"revoke": "रद्द करें",
"empty": "वर्तमान फ़िल्टर से कोई सत्र मेल नहीं खाता।",
"revoke_self_confirm": "⚠️ यह आपका वर्तमान सत्र है। इसे रद्द करने पर आप तुरंत साइन आउट हो जाएंगे और आपको दोबारा साइन इन करना होगा। जारी रखें?",
"revoke_confirm": "इस सत्र को रद्द करें? उस ब्राउज़र से अगला अनुरोध 401 होगा।",
"revoke_lag_notice": "किसी सत्र को रद्द करने से उसका रीफ़्रेश पथ तुरंत टूट जाता है, लेकिन ब्राउज़र में पहले से मौजूद कोई भी JWT अगले रीफ़्रेश प्रयास तक {{secs}} सेकंड तक मान्य रहता है।",
"origin": {
"password": "पासवर्ड",
"opaque": "OPAQUE",
"magic_link": "मैजिक लिंक",
"oidc": "SSO",
"device": "डिवाइस",
"unknown": "अज्ञात"
}
},
"smtp_fail": "भेजना विफल।",
"smtp_send": "भेजें",
"smtp_test": "परीक्षण ईमेल भेजें",
@@ -1341,4 +1374,4 @@
"wrong_drop_zone_msg": "अपलोड केवल फ़ाइलें अनुभाग में काम करता है — फ़ाइलें अनुभाग खोलें और वहीं छोड़ें।",
"wrong_drop_zone_action": "फ़ाइलों पर जाएँ"
}
}
}
+35 -2
View File
@@ -564,7 +564,8 @@
"magic_hint": "Niente password? Inserisci la tua email e ti invieremo un link di accesso monouso.",
"magic_unavailable": "L'accesso tramite email non è disponibile su questo server.",
"passwords_match": "Le password corrispondono",
"sign_in": "Accedi"
"sign_in": "Accedi",
"logged_out": "Disconnessione effettuata."
},
"storage": {
"title": "Archiviazione",
@@ -842,6 +843,38 @@
"quotas": "Quote",
"reset_pw_for": "Nuova password per",
"role": "Ruolo",
"sessions": {
"filter_user": "Utente (UUID)",
"include_revoked": "Includi revocate / scadute",
"refresh": "Aggiorna",
"col_user": "Utente",
"col_origin": "Origine",
"col_created": "Creata",
"col_expires": "Scade",
"col_ip": "IP",
"col_user_agent": "User agent",
"col_bound": "Collegata",
"col_status": "Stato",
"current_tooltip": "Questa è la sessione che stai usando adesso — revocarla ti disconnetterà.",
"bound_tooltip": "Collegata a DPoP (jkt {{prefix}}…)",
"unbound": "non collegata",
"revoked": "revocata",
"expired": "scaduta",
"active": "attiva",
"revoke": "Revoca",
"empty": "Nessuna sessione corrisponde al filtro attuale.",
"revoke_self_confirm": "⚠️ Questa è la TUA sessione corrente. Revocarla ti disconnetterà immediatamente e dovrai accedere di nuovo. Continuare?",
"revoke_confirm": "Revocare questa sessione? La prossima richiesta da quel browser risponderà 401.",
"revoke_lag_notice": "Revocare una sessione interrompe immediatamente il suo percorso di aggiornamento, ma qualsiasi JWT già presente nel browser rimane valido fino a {{secs}} secondi, fino al prossimo tentativo di aggiornamento.",
"origin": {
"password": "Password",
"opaque": "OPAQUE",
"magic_link": "Link magico",
"oidc": "SSO",
"device": "Dispositivo",
"unknown": "Sconosciuto"
}
},
"smtp_fail": "Invio non riuscito.",
"smtp_send": "Invia",
"smtp_test": "Invia email di prova",
@@ -1341,4 +1374,4 @@
"wrong_drop_zone_msg": "I caricamenti funzionano solo in File — apri la sezione File e trascina lì gli elementi.",
"wrong_drop_zone_action": "Vai a File"
}
}
}
+35 -2
View File
@@ -564,7 +564,8 @@
"magic_hint": "パスワードをお持ちでない方は、メールアドレスを入力するとワンタイムサインインリンクをお送りします。",
"magic_unavailable": "このサーバーではメールでのサインインは利用できません。",
"passwords_match": "Passwords match",
"sign_in": "サインイン"
"sign_in": "サインイン",
"logged_out": "サインアウトしました。"
},
"storage": {
"title": "ストレージ",
@@ -842,6 +843,38 @@
"quotas": "クォータ",
"reset_pw_for": "新しいパスワード:",
"role": "役割",
"sessions": {
"filter_user": "ユーザー (UUID)",
"include_revoked": "取り消し済み / 期限切れを含む",
"refresh": "更新",
"col_user": "ユーザー",
"col_origin": "由来",
"col_created": "作成日時",
"col_expires": "有効期限",
"col_ip": "IP",
"col_user_agent": "ユーザーエージェント",
"col_bound": "バインド",
"col_status": "状態",
"current_tooltip": "これは現在使用中のセッションです — 取り消すとサインアウトされます。",
"bound_tooltip": "DPoP にバインド済み (jkt {{prefix}}…)",
"unbound": "バインドなし",
"revoked": "取り消し済み",
"expired": "期限切れ",
"active": "有効",
"revoke": "取り消し",
"empty": "現在のフィルターに一致するセッションはありません。",
"revoke_self_confirm": "⚠️ これはあなたの現在のセッションです。取り消すと直ちにサインアウトされ、再度サインインが必要になります。続行しますか?",
"revoke_confirm": "このセッションを取り消しますか? そのブラウザからの次のリクエストは 401 になります。",
"revoke_lag_notice": "セッションを取り消すと、リフレッシュ経路は直ちに切断されますが、ブラウザにすでに存在する JWT は次のリフレッシュ試行まで最大 {{secs}} 秒間有効なままです。",
"origin": {
"password": "パスワード",
"opaque": "OPAQUE",
"magic_link": "マジックリンク",
"oidc": "SSO",
"device": "デバイス",
"unknown": "不明"
}
},
"smtp_fail": "送信に失敗しました。",
"smtp_send": "送信",
"smtp_test": "テストメールを送信",
@@ -1341,4 +1374,4 @@
"wrong_drop_zone_msg": "アップロードはファイル セクションでのみ機能します。ファイル セクションを開いてそこにドロップしてください。",
"wrong_drop_zone_action": "ファイルへ移動"
}
}
}
+34 -1
View File
@@ -687,6 +687,7 @@
"magic_sent": "해당 계정이 존재하면 로그인 링크가 전송되었습니다. 받은편지함을 확인하세요.",
"register_error": "가입 실패",
"session_expired": "세션이 만료되었습니다. 다시 로그인해 주세요.",
"logged_out": "로그아웃되었습니다.",
"signing_in": "로그인 중…",
"toggle_password": "비밀번호 표시"
},
@@ -969,6 +970,38 @@
"quotas": "할당량",
"reset_pw_for": "새 비밀번호:",
"role": "역할",
"sessions": {
"filter_user": "사용자 (UUID)",
"include_revoked": "취소됨 / 만료됨 포함",
"refresh": "새로 고침",
"col_user": "사용자",
"col_origin": "출처",
"col_created": "생성일",
"col_expires": "만료일",
"col_ip": "IP",
"col_user_agent": "사용자 에이전트",
"col_bound": "바인딩",
"col_status": "상태",
"current_tooltip": "지금 사용 중인 세션입니다 — 이 세션을 취소하면 로그아웃됩니다.",
"bound_tooltip": "DPoP 바인딩됨 (jkt {{prefix}}…)",
"unbound": "미바인딩",
"revoked": "취소됨",
"expired": "만료됨",
"active": "활성",
"revoke": "취소",
"empty": "현재 필터와 일치하는 세션이 없습니다.",
"revoke_self_confirm": "⚠️ 현재 사용 중인 세션입니다. 취소하면 즉시 로그아웃되고 다시 로그인해야 합니다. 계속하시겠습니까?",
"revoke_confirm": "이 세션을 취소하시겠습니까? 해당 브라우저의 다음 요청은 401을 받게 됩니다.",
"revoke_lag_notice": "세션을 취소하면 새로 고침 경로가 즉시 끊어지지만, 브라우저에 이미 있는 JWT는 다음 새로 고침 시도까지 최대 {{secs}}초 동안 유효합니다.",
"origin": {
"password": "비밀번호",
"opaque": "OPAQUE",
"magic_link": "매직 링크",
"oidc": "SSO",
"device": "장치",
"unknown": "알 수 없음"
}
},
"smtp_fail": "전송 실패.",
"smtp_send": "보내기",
"smtp_test": "테스트 이메일 보내기",
@@ -1696,4 +1729,4 @@
"wrong_drop_zone_msg": "업로드는 파일 섹션에서만 작동합니다. 파일 섹션을 열고 거기에 놓아 주세요.",
"wrong_drop_zone_action": "파일로 이동"
}
}
}
+35 -2
View File
@@ -564,7 +564,8 @@
"magic_hint": "Geen wachtwoord? Voer uw e-mailadres in en we sturen u een eenmalige aanmeldlink.",
"magic_unavailable": "Aanmelden per e-mail is niet beschikbaar op deze server.",
"passwords_match": "Passwords match",
"sign_in": "Inloggen"
"sign_in": "Inloggen",
"logged_out": "Succesvol afgemeld."
},
"storage": {
"title": "Opslag",
@@ -842,6 +843,38 @@
"quotas": "Quota",
"reset_pw_for": "Nieuw wachtwoord voor",
"role": "Rol",
"sessions": {
"filter_user": "Gebruiker (UUID)",
"include_revoked": "Ingetrokken / verlopen tonen",
"refresh": "Vernieuwen",
"col_user": "Gebruiker",
"col_origin": "Herkomst",
"col_created": "Aangemaakt",
"col_expires": "Verloopt",
"col_ip": "IP",
"col_user_agent": "User agent",
"col_bound": "Gebonden",
"col_status": "Status",
"current_tooltip": "Dit is de sessie die u nu gebruikt — intrekken meldt u af.",
"bound_tooltip": "Gebonden met DPoP (jkt {{prefix}}…)",
"unbound": "niet gebonden",
"revoked": "ingetrokken",
"expired": "verlopen",
"active": "actief",
"revoke": "Intrekken",
"empty": "Geen sessies komen overeen met het huidige filter.",
"revoke_self_confirm": "⚠️ Dit is UW huidige sessie. Intrekken meldt u direct af en u moet opnieuw inloggen. Doorgaan?",
"revoke_confirm": "Deze sessie intrekken? Het volgende verzoek van die browser krijgt 401.",
"revoke_lag_notice": "Het intrekken van een sessie verbreekt onmiddellijk het vernieuwingspad, maar elke JWT die al in de browser aanwezig is, blijft geldig tot {{secs}} seconden, tot de volgende vernieuwingspoging.",
"origin": {
"password": "Wachtwoord",
"opaque": "OPAQUE",
"magic_link": "Magische link",
"oidc": "SSO",
"device": "Apparaat",
"unknown": "Onbekend"
}
},
"smtp_fail": "Verzenden mislukt.",
"smtp_send": "Verzenden",
"smtp_test": "Test-e-mail verzenden",
@@ -1341,4 +1374,4 @@
"wrong_drop_zone_msg": "Uploaden werkt alleen in Bestanden — open het onderdeel Bestanden en zet ze daar neer.",
"wrong_drop_zone_action": "Naar Bestanden"
}
}
}
+35 -2
View File
@@ -564,7 +564,8 @@
"magic_hint": "Brak hasła? Wpisz swój adres e-mail, a wyślemy Ci jednorazowy link do logowania.",
"magic_unavailable": "Logowanie e-mailem nie jest dostępne na tym serwerze.",
"passwords_match": "Passwords match",
"sign_in": "Zaloguj się"
"sign_in": "Zaloguj się",
"logged_out": "Wylogowano pomyślnie."
},
"storage": {
"title": "Pamięć masowa",
@@ -842,6 +843,38 @@
"quotas": "Przydziały",
"reset_pw_for": "Nowe hasło dla",
"role": "Rola",
"sessions": {
"filter_user": "Użytkownik (UUID)",
"include_revoked": "Uwzględnij unieważnione / wygasłe",
"refresh": "Odśwież",
"col_user": "Użytkownik",
"col_origin": "Pochodzenie",
"col_created": "Utworzono",
"col_expires": "Wygasa",
"col_ip": "IP",
"col_user_agent": "User agent",
"col_bound": "Powiązana",
"col_status": "Stan",
"current_tooltip": "To sesja, której obecnie używasz — unieważnienie jej wyloguje Cię.",
"bound_tooltip": "Powiązana z DPoP (jkt {{prefix}}…)",
"unbound": "niepowiązana",
"revoked": "unieważniona",
"expired": "wygasła",
"active": "aktywna",
"revoke": "Unieważnij",
"empty": "Żadne sesje nie pasują do bieżącego filtra.",
"revoke_self_confirm": "⚠️ To TWOJA bieżąca sesja. Unieważnienie jej wyloguje Cię natychmiast i musisz zalogować się ponownie. Kontynuować?",
"revoke_confirm": "Unieważnić tę sesję? Następne żądanie z tej przeglądarki otrzyma 401.",
"revoke_lag_notice": "Unieważnienie sesji natychmiast przerywa ścieżkę odświeżania, ale każdy JWT już obecny w przeglądarce pozostaje ważny przez maksymalnie {{secs}} sekund, aż do następnej próby odświeżenia.",
"origin": {
"password": "Hasło",
"opaque": "OPAQUE",
"magic_link": "Magiczny link",
"oidc": "SSO",
"device": "Urządzenie",
"unknown": "Nieznane"
}
},
"smtp_fail": "Wysłanie nie powiodło się.",
"smtp_send": "Wyślij",
"smtp_test": "Wyślij e-mail testowy",
@@ -1341,4 +1374,4 @@
"wrong_drop_zone_msg": "Przesyłanie działa tylko w Plikach — otwórz sekcję Pliki i upuść tam pliki.",
"wrong_drop_zone_action": "Przejdź do Plików"
}
}
}
+35 -2
View File
@@ -564,7 +564,8 @@
"magic_hint": "Sem senha? Digite seu e-mail e enviaremos um link de acesso único.",
"magic_unavailable": "O acesso por e-mail não está disponível neste servidor.",
"passwords_match": "As palavras-passe coincidem",
"sign_in": "Entrar"
"sign_in": "Entrar",
"logged_out": "Sessão terminada com sucesso."
},
"storage": {
"title": "Armazenamento",
@@ -842,6 +843,38 @@
"quotas": "Cotas",
"reset_pw_for": "Nova senha para",
"role": "Função",
"sessions": {
"filter_user": "Utilizador (UUID)",
"include_revoked": "Incluir revogadas / expiradas",
"refresh": "Atualizar",
"col_user": "Utilizador",
"col_origin": "Origem",
"col_created": "Criada",
"col_expires": "Expira",
"col_ip": "IP",
"col_user_agent": "Agente de utilizador",
"col_bound": "Vinculada",
"col_status": "Estado",
"current_tooltip": "Esta é a sessão que está a utilizar agora — revogá-la irá terminar a sua sessão.",
"bound_tooltip": "Vinculada por DPoP (jkt {{prefix}}…)",
"unbound": "não vinculada",
"revoked": "revogada",
"expired": "expirada",
"active": "ativa",
"revoke": "Revogar",
"empty": "Nenhuma sessão corresponde ao filtro atual.",
"revoke_self_confirm": "⚠️ Esta é a SUA sessão atual. Revogá-la irá terminar a sessão imediatamente e terá de iniciar sessão novamente. Continuar?",
"revoke_confirm": "Revogar esta sessão? O próximo pedido desse browser receberá 401.",
"revoke_lag_notice": "Revogar uma sessão interrompe imediatamente o seu caminho de renovação, mas qualquer JWT já presente no navegador permanece válido por até {{secs}} segundos, até à próxima tentativa de renovação.",
"origin": {
"password": "Palavra-passe",
"opaque": "OPAQUE",
"magic_link": "Ligação mágica",
"oidc": "SSO",
"device": "Dispositivo",
"unknown": "Desconhecido"
}
},
"smtp_fail": "Falha no envio.",
"smtp_send": "Enviar",
"smtp_test": "Enviar e-mail de teste",
@@ -1341,4 +1374,4 @@
"wrong_drop_zone_msg": "Os envios só funcionam em Ficheiros — abra a secção Ficheiros e largue-os aí.",
"wrong_drop_zone_action": "Ir para Arquivos"
}
}
}
+35 -2
View File
@@ -564,7 +564,8 @@
"magic_hint": "Нет пароля? Введите ваш email, и мы пришлём вам одноразовую ссылку для входа.",
"magic_unavailable": "Вход по электронной почте недоступен на этом сервере.",
"passwords_match": "Passwords match",
"sign_in": "Вход"
"sign_in": "Вход",
"logged_out": "Вы успешно вышли."
},
"storage": {
"title": "Хранилище",
@@ -842,6 +843,38 @@
"quotas": "Квоты",
"reset_pw_for": "Новый пароль для",
"role": "Роль",
"sessions": {
"filter_user": "Пользователь (UUID)",
"include_revoked": "Включая отозванные / истёкшие",
"refresh": "Обновить",
"col_user": "Пользователь",
"col_origin": "Источник",
"col_created": "Создан",
"col_expires": "Истекает",
"col_ip": "IP",
"col_user_agent": "Агент пользователя",
"col_bound": "Привязан",
"col_status": "Статус",
"current_tooltip": "Это сеанс, который вы сейчас используете — при отзыве вы будете разлогинены.",
"bound_tooltip": "Привязан к DPoP (jkt {{prefix}}…)",
"unbound": "не привязан",
"revoked": "отозван",
"expired": "истёк",
"active": "активен",
"revoke": "Отозвать",
"empty": "Нет сеансов, соответствующих текущему фильтру.",
"revoke_self_confirm": "⚠️ Это ВАШ текущий сеанс. При отзыве вы будете немедленно разлогинены и придётся войти снова. Продолжить?",
"revoke_confirm": "Отозвать этот сеанс? Следующий запрос из этого браузера получит 401.",
"revoke_lag_notice": "Отзыв сессии немедленно прерывает путь её обновления, но любой JWT, уже находящийся в браузере, остаётся действительным до {{secs}} секунд, до следующей попытки обновления.",
"origin": {
"password": "Пароль",
"opaque": "OPAQUE",
"magic_link": "Волшебная ссылка",
"oidc": "SSO",
"device": "Устройство",
"unknown": "Неизвестно"
}
},
"smtp_fail": "Сбой отправки.",
"smtp_send": "Отправить",
"smtp_test": "Отправить тестовое письмо",
@@ -1341,4 +1374,4 @@
"wrong_drop_zone_msg": "Загрузки работают только в разделе Файлы — откройте раздел Файлы и перетащите туда.",
"wrong_drop_zone_action": "Перейти к файлам"
}
}
}
+35 -2
View File
@@ -564,7 +564,8 @@
"magic_hint": "沒有密碼?輸入您的電子郵件,我們將向您發送一次性登入連結。",
"magic_unavailable": "此伺服器不支援電子郵件登入。",
"passwords_match": "Passwords match",
"sign_in": "登入"
"sign_in": "登入",
"logged_out": "已成功登出。"
},
"storage": {
"title": "儲存空間",
@@ -825,6 +826,38 @@
"quotas": "配額",
"reset_pw_for": "新密碼用於",
"role": "角色",
"sessions": {
"filter_user": "使用者 (UUID)",
"include_revoked": "包含已撤銷 / 已過期",
"refresh": "重新整理",
"col_user": "使用者",
"col_origin": "來源",
"col_created": "建立於",
"col_expires": "過期於",
"col_ip": "IP",
"col_user_agent": "使用者代理",
"col_bound": "已繫結",
"col_status": "狀態",
"current_tooltip": "這是您正在使用的工作階段 — 撤銷會將您登出。",
"bound_tooltip": "已繫結 DPoP (jkt {{prefix}}…)",
"unbound": "未繫結",
"revoked": "已撤銷",
"expired": "已過期",
"active": "使用中",
"revoke": "撤銷",
"empty": "沒有工作階段符合目前的篩選條件。",
"revoke_self_confirm": "⚠️ 這是您目前的工作階段。撤銷後將立即登出,您必須重新登入。要繼續嗎?",
"revoke_confirm": "撤銷此工作階段?該瀏覽器的下一次請求將回傳 401。",
"revoke_lag_notice": "撤銷工作階段會立即中斷其重新整理路徑,但瀏覽器中已存在的任何 JWT 在下次重新整理嘗試之前最多可保持有效 {{secs}} 秒。",
"origin": {
"password": "密碼",
"opaque": "OPAQUE",
"magic_link": "魔法連結",
"oidc": "SSO",
"device": "裝置",
"unknown": "未知"
}
},
"smtp_fail": "傳送失敗。",
"smtp_send": "傳送",
"smtp_test": "傳送測試郵件",
@@ -1341,4 +1374,4 @@
"wrong_drop_zone_msg": "上傳僅在「檔案」中有效 — 請開啟檔案區並在該處拖放。",
"wrong_drop_zone_action": "前往檔案"
}
}
}
+35 -2
View File
@@ -564,7 +564,8 @@
"magic_hint": "没有密码?输入您的邮箱,我们将向您发送一次性登录链接。",
"magic_unavailable": "此服务器不支持邮箱登录。",
"passwords_match": "Passwords match",
"sign_in": "登录"
"sign_in": "登录",
"logged_out": "已成功退出。"
},
"storage": {
"title": "存储空间",
@@ -825,6 +826,38 @@
"quotas": "配额",
"reset_pw_for": "新密码用于",
"role": "角色",
"sessions": {
"filter_user": "用户 (UUID)",
"include_revoked": "包含已撤销 / 已过期",
"refresh": "刷新",
"col_user": "用户",
"col_origin": "来源",
"col_created": "创建于",
"col_expires": "过期于",
"col_ip": "IP",
"col_user_agent": "用户代理",
"col_bound": "已绑定",
"col_status": "状态",
"current_tooltip": "这是您正在使用的会话 — 撤销将使您退出登录。",
"bound_tooltip": "已绑定 DPoP (jkt {{prefix}}…)",
"unbound": "未绑定",
"revoked": "已撤销",
"expired": "已过期",
"active": "活动",
"revoke": "撤销",
"empty": "没有会话与当前筛选条件匹配。",
"revoke_self_confirm": "⚠️ 这是您当前的会话。撤销后您将立即被注销并需要重新登录。是否继续?",
"revoke_confirm": "撤销此会话吗?该浏览器的下一次请求将返回 401。",
"revoke_lag_notice": "撤销会话会立即中断其刷新路径,但浏览器中已存在的任何 JWT 在下次刷新尝试之前最多可保持有效 {{secs}} 秒。",
"origin": {
"password": "密码",
"opaque": "OPAQUE",
"magic_link": "魔法链接",
"oidc": "SSO",
"device": "设备",
"unknown": "未知"
}
},
"smtp_fail": "发送失败。",
"smtp_send": "发送",
"smtp_test": "发送测试邮件",
@@ -1341,4 +1374,4 @@
"wrong_drop_zone_msg": "上传仅在 “文件” 中生效 — 请打开文件区并在那里拖放。",
"wrong_drop_zone_action": "前往文件"
}
}
}
+26 -11
View File
@@ -19,18 +19,33 @@ const BACKEND = process.env.OXICLOUD_BACKEND ?? 'http://localhost:8086';
// by default so normal dev/release builds carry no instrumentation overhead.
const COVERAGE = process.env.COVERAGE === '1';
// `changeOrigin: true` rewrites the `Host` header to match the backend's
// authority (localhost:8086) so the backend answers as if the request
// arrived natively. But DPoP's `htu` claim is bound to what the browser
// sees (localhost:5173) — a bare rewrite makes the server compute
// `htu = http://localhost:8086/api/…` and fire `dpop.verify_failed
// reason=wrong_htu` on every request. Set `X-Forwarded-*` so the DPoP
// middleware (which mirrors production reverse-proxy behaviour) can
// reconstruct the browser-visible URL. Same reasoning that applies to
// nginx/Cloudflare in front of the deployment applies to Vite in dev.
const DEV_ORIGIN_HEADERS = {
'X-Forwarded-Proto': 'http',
'X-Forwarded-Host': 'localhost:5173'
};
const p = (target: string) => ({ target, changeOrigin: true, headers: DEV_ORIGIN_HEADERS });
const proxy = {
'/api': { target: BACKEND, changeOrigin: true },
'/locales': { target: BACKEND, changeOrigin: true },
'/.well-known': { target: BACKEND, changeOrigin: true },
'/remote.php': { target: BACKEND, changeOrigin: true },
'/ocs': { target: BACKEND, changeOrigin: true },
'/status.php': { target: BACKEND, changeOrigin: true },
'/webdav': { target: BACKEND, changeOrigin: true },
'/caldav': { target: BACKEND, changeOrigin: true },
'/carddav': { target: BACKEND, changeOrigin: true },
'/wopi': { target: BACKEND, changeOrigin: true },
'/magic': { target: BACKEND, changeOrigin: true }
'/api': p(BACKEND),
'/locales': p(BACKEND),
'/.well-known': p(BACKEND),
'/remote.php': p(BACKEND),
'/ocs': p(BACKEND),
'/status.php': p(BACKEND),
'/webdav': p(BACKEND),
'/caldav': p(BACKEND),
'/carddav': p(BACKEND),
'/wopi': p(BACKEND),
'/magic': p(BACKEND)
};
export default defineConfig({
+146
View File
@@ -54,3 +54,149 @@ for (const name of ['localStorage', 'sessionStorage']) {
g[name] = new MemoryStorage() as unknown as Storage;
}
}
// jsdom has no IndexedDB. `$lib/auth/dpop` uses it as a single-entry
// key/value store for the browser DPoP keypair; without a working
// backing store every test that touches login / fetch prints a
// fail-open `console.debug` on stdout. Rather than pull in
// `fake-indexeddb` for one call-site, provide a minimal in-memory
// shim that covers exactly the API surface `dpop.ts` uses:
//
// indexedDB.open(name) → IDBOpenDBRequest
// .onupgradeneeded / .onsuccess / .onerror → callback slots
// .result → { objectStoreNames.contains,
// createObjectStore, transaction, close }
// store.get(key) / .put(value, key) / .delete(key)
// tx.oncomplete / .onerror
//
// Tests that WANT to exercise DPoP semantics still mock the module
// (see `src/lib/auth/dpop-proof.test.ts`). This shim is for the
// login-path traversals that were noisy without it.
if (!g.indexedDB) {
type Store = Map<string, unknown>;
type Db = {
stores: Map<string, Store>;
objectStoreNames: { contains: (n: string) => boolean };
createObjectStore: (n: string) => void;
transaction: (n: string, mode: 'readonly' | 'readwrite') => FakeTx;
close: () => void;
};
type FakeReq<T> = {
result: T | undefined;
error: unknown;
onsuccess: ((this: unknown, ev: Event) => void) | null;
onerror: ((this: unknown, ev: Event) => void) | null;
};
type FakeTx = {
objectStore: (n: string) => FakeStore;
oncomplete: ((this: unknown, ev: Event) => void) | null;
onerror: ((this: unknown, ev: Event) => void) | null;
_done: () => void;
};
type FakeStore = {
get: (key: string) => FakeReq<unknown>;
put: (value: unknown, key: string) => FakeReq<void>;
delete: (key: string) => FakeReq<void>;
};
// Per-database persistence: opening the same name again gives you
// back your previously-created stores + entries, so the module's
// "read a value someone else wrote" flow works across
// open→close→open cycles within one test.
const databases = new Map<string, Map<string, Store>>();
function makeStore(map: Store, tx: FakeTx): FakeStore {
const microDone = () => queueMicrotask(() => tx._done());
return {
get(key: string): FakeReq<unknown> {
const req: FakeReq<unknown> = {
result: map.get(key),
error: undefined,
onsuccess: null,
onerror: null
};
queueMicrotask(() => req.onsuccess?.call(req, new Event('success')));
microDone();
return req;
},
put(value: unknown, key: string): FakeReq<void> {
map.set(key, value);
const req: FakeReq<void> = {
result: undefined,
error: undefined,
onsuccess: null,
onerror: null
};
queueMicrotask(() => req.onsuccess?.call(req, new Event('success')));
microDone();
return req;
},
delete(key: string): FakeReq<void> {
map.delete(key);
const req: FakeReq<void> = {
result: undefined,
error: undefined,
onsuccess: null,
onerror: null
};
queueMicrotask(() => req.onsuccess?.call(req, new Event('success')));
microDone();
return req;
}
};
}
function makeDb(name: string): Db {
let stores = databases.get(name);
if (!stores) {
stores = new Map();
databases.set(name, stores);
}
return {
stores,
objectStoreNames: { contains: (n: string) => stores!.has(n) },
createObjectStore(n: string): void {
if (!stores!.has(n)) stores!.set(n, new Map());
},
transaction(n: string, _mode: 'readonly' | 'readwrite'): FakeTx {
const store = stores!.get(n);
if (!store) throw new Error(`fake-idb: store '${n}' not found`);
const tx: FakeTx = {
objectStore: () => makeStore(store, tx),
oncomplete: null,
onerror: null,
_done: () => tx.oncomplete?.call(tx, new Event('complete'))
};
return tx;
},
close(): void {
/* no-op — databases map keeps state across close */
}
};
}
g.indexedDB = {
open(name: string) {
const req: FakeReq<Db> & {
onupgradeneeded: ((this: unknown, ev: Event) => void) | null;
} = {
result: undefined,
error: undefined,
onsuccess: null,
onerror: null,
onupgradeneeded: null
};
queueMicrotask(() => {
const db = makeDb(name);
req.result = db;
// Fire upgradeneeded on FIRST open per database, so the
// module can call createObjectStore('keypair') exactly
// like the real API expects.
const stores = databases.get(name)!;
if (stores.size === 0) req.onupgradeneeded?.call(req, new Event('upgradeneeded'));
req.onsuccess?.call(req, new Event('success'));
});
return req;
}
};
}
-9
View File
@@ -216,15 +216,6 @@ frontend-check: front-design
front-test: fe-build-e2e
cd tests/e2e && npm run test:coverage
# Records against a throwaway container stack (its own Postgres + the OxiCloud
# SPA). Each starting point is a file in tests/e2e/scenarios/codegen/ that sets
# up state then calls page.pause(); drop a new *.spec.ts there to add one — this
# menu discovers them automatically.
# Interactive Playwright codegen — pick a starting point, then record
front-codegen:
bash tests/e2e/scripts/codegen.sh
# Frontend design-system guardrails — pure Node, no extra deps, run against the
# SvelteKit frontend (frontend/). Locale completeness, dead-token report, and
# brand-mark drift. For the full svelte-check/eslint/stylelint/prettier gate use
@@ -0,0 +1,34 @@
-- Bind a session cookie to a browser-held ECDSA keypair (DPoP, RFC 9449).
--
-- Each browser session that supports Web Crypto generates a P-256 keypair
-- with `extractable: false` and stores it in IndexedDB. The public-key JWK
-- thumbprint (RFC 7638, base64url-encoded SHA-256) is sent with the login
-- request and stored here. Middleware then requires every subsequent
-- request on the session to carry a valid DPoP proof signed by the paired
-- private key. Stealing the cookie alone gets an attacker nothing — the
-- private key never leaves the browser's crypto subsystem.
--
-- Nullable because:
-- * pre-DPoP sessions created before this feature landed;
-- * app-password / Nextcloud-client sessions (Basic Auth, no browser,
-- no Web Crypto) will always have NULL here and are exempted at the
-- middleware;
-- * browsers without SubtleCrypto (very rare in 2026) fail the client-
-- side keypair generation and log in unbound (fail-open per the
-- `docs/plan/dpop.md` threat model).
--
-- Immutable per-session: set at INSERT time, never updated. That's the
-- point — otherwise an attacker could downgrade a bound session by
-- clearing the column.
--
-- Length is 43 characters for a base64url-encoded SHA-256 (32 bytes ×
-- 4/3 = 43 chars, no padding). Cap at 64 to leave a little slack in
-- case we later support larger thumbprints (e.g. SHA-384 for P-384).
--
-- No index needed — the column is read alongside the session row by
-- primary key in the auth middleware, never queried in isolation.
ALTER TABLE auth.sessions
ADD COLUMN IF NOT EXISTS dpop_jkt VARCHAR(64);
COMMENT ON COLUMN auth.sessions.dpop_jkt IS
'DPoP JWK thumbprint (RFC 7638) binding this session to a browser-held keypair. NULL for app-password / legacy / unbound sessions.';
@@ -0,0 +1,36 @@
-- Session origin — how the row was minted.
--
-- Populated at session-mint time by each login handler (legacy password,
-- OPAQUE aPAKE, magic-link redemption, OIDC callback, RFC 8628 device
-- authorization). Refresh copies the parent session's origin (a refresh
-- doesn't change how the user originally authenticated). Existing rows
-- predating this column default to `unknown`.
--
-- Purpose: gives admins a first-class filter on the sessions panel
-- ("show me only the OIDC sessions", "spot the magic-link ones during
-- a suspected phishing wave") without them having to infer from
-- adjacent fields (`oidc_id_token IS NOT NULL` etc.). Also drives
-- correlation with audit lines that already carry the same enum.
--
-- Stored as `text` rather than a PG ENUM: enums lock the schema (adding
-- a new variant needs a migration + release coordination), whereas a
-- checked text column can gain values by editing the constraint. The
-- Rust `SessionOrigin` enum uses `#[serde(rename_all = "snake_case")]`
-- so wire values match column values one-to-one.
--
-- No index — origin is a display column read alongside the row by PK;
-- filtering happens client-side in the admin panel (page size caps at
-- 100, so scanning is fine).
ALTER TABLE auth.sessions
ADD COLUMN IF NOT EXISTS origin TEXT NOT NULL DEFAULT 'unknown';
-- Enforce the known values at the storage layer so a rogue INSERT
-- can't smuggle an arbitrary string that would then confuse the
-- serde-typed enum deserialize on read. Adding a new variant is a
-- one-line ALTER + Rust enum change.
ALTER TABLE auth.sessions
ADD CONSTRAINT sessions_origin_known
CHECK (origin IN ('password', 'opaque', 'magic_link', 'oidc', 'device', 'unknown'));
COMMENT ON COLUMN auth.sessions.origin IS
'How this session was minted: password | opaque | magic_link | oidc | device | unknown. Set at INSERT time by the login handler; carried over on refresh.';
+1
View File
@@ -20,6 +20,7 @@ pub mod playlist_dto;
pub mod plugin_dto;
pub mod recent_dto;
pub mod search_dto;
pub mod session_dto;
pub mod settings_dto;
pub mod share_dto;
pub mod trash_dto;
+266
View File
@@ -0,0 +1,266 @@
//! DTOs for the admin sessions panel.
//!
//! [`SessionSummaryDto`] is the wire shape returned by
//! `GET /api/admin/sessions`. It's deliberately narrower than the
//! `Session` domain entity — the `refresh_token` and any OIDC
//! ID-token payload are **never** serialized; the raw DPoP thumbprint
//! is truncated to an 8-char prefix so an admin viewing another
//! user's sessions cannot exfiltrate the full binding fingerprint.
//!
//! Enrichment (username/email lookup for each `user_id`) is
//! intentionally deferred to the SPA — it already caches the admin
//! user list, and doing the JOIN server-side would either force a
//! per-request JOIN (extra work most operators don't need) or a
//! separate batch fetch (extra round-trip). Frontend cross-references
//! `user_id` against its cached user list.
use chrono::{DateTime, Utc};
use serde::Serialize;
use utoipa::ToSchema;
use uuid::Uuid;
use crate::domain::entities::session::{Session, SessionOrigin};
/// Authenticated-caller context — the caller's identity + session-
/// bound signals a service method might key off. Constructed at the
/// handler boundary from `AuthUser` and passed through unchanged;
/// keeps service signatures flat instead of accumulating parallel
/// `caller_id`, `caller_jkt`, `caller_ip` parameters. Every
/// caller-context field lives here, one place to extend.
///
/// Not admin-specific — any handler that needs caller context can
/// build one from `AuthUser`. Admin methods just happen to be the
/// first callers (sessions panel's `is_current` comparison and
/// audit lines).
#[derive(Debug, Clone)]
pub struct SessionCaller<'a> {
/// AuthZ subject — used by `require_admin_caller` and audit lines.
pub id: Uuid,
/// Caller's own DPoP thumbprint from the JWT `cnf.jkt` claim.
/// Enables the sessions panel's "you are here" highlight
/// ([`SessionSummaryDto::is_current`]) — `None` when the caller
/// logged in via an unbound path (legacy password without DPoP,
/// pre-bind OIDC redirect, etc.).
pub dpop_jkt: Option<&'a str>,
}
/// Wire shape for `GET /api/admin/sessions`. Contains everything the
/// admin table renders and **nothing the raw session entity would
/// leak** (refresh token, OIDC ID-token, full DPoP thumbprint).
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct SessionSummaryDto {
pub id: Uuid,
pub user_id: Uuid,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
pub ip_address: Option<String>,
pub user_agent: Option<String>,
/// `true` iff the session is DPoP-bound. Rendered as a lock icon
/// in the admin table. Complements the auth-badges surface.
pub is_bound: bool,
/// First 8 chars of the DPoP thumbprint when bound, `None` otherwise.
/// Enough to distinguish two bindings of the same user across
/// devices at a glance; not enough to leak the full jkt.
pub dpop_jkt_prefix: Option<String>,
/// `true` when the row is revoked. Present because the panel has an
/// opt-in "include revoked" checkbox — active-only listings will
/// always show `false` here, but forensics listings need the flag.
pub is_revoked: bool,
/// Whether this row is currently usable — `!revoked && expires_at > now()`.
/// Kept server-side so the SPA doesn't drift if the browser clock is off.
pub is_active: bool,
// NOTE: no `oidc_sid` / `oidc_sid_prefix` field. The IdP-emitted
// sid identifies the row's upstream session and stays server-side
// (used by Back-Channel Logout matching). Exposing even a prefix
// earns no operator utility over what `id` / `created_at` /
// `ip_address` / `user_agent` already give. `origin` below answers
// "how did this session start?" cleanly.
/// How the session was minted — see [`SessionOrigin`]. Set at
/// INSERT time by the login handler and carried over on refresh
/// (rotation doesn't change how the user first authenticated).
/// Drives the admin panel's origin column + filter.
pub origin: SessionOrigin,
/// `true` when this row IS the caller's currently-active session —
/// set by the service layer by comparing the row's `dpop_jkt` with
/// the caller's own bound thumbprint. Lets the admin panel flag
/// "revoking this cuts your own branch" so an admin doesn't
/// accidentally log themselves out. `false` when either side is
/// unbound (can't correlate) or when the jkts don't match.
pub is_current: bool,
}
impl From<Session> for SessionSummaryDto {
fn from(s: Session) -> Self {
Self::from_session(s, None)
}
}
impl SessionSummaryDto {
/// Build the DTO with an optional `caller_jkt` used to compute
/// `is_current`. Pass the admin caller's DPoP thumbprint to have
/// the panel highlight the caller's own row; pass `None` when
/// the caller is unbound (no jkt = no correlation) or from
/// non-admin contexts.
pub fn from_session(s: Session, caller_jkt: Option<&str>) -> Self {
let is_revoked = s.is_revoked();
let is_expired = s.is_expired();
let jkt = s.dpop_jkt().map(|s| s.to_owned());
let dpop_jkt_prefix = jkt.as_ref().map(|t| t.chars().take(8).collect::<String>());
let is_current = match (jkt.as_deref(), caller_jkt) {
(Some(row), Some(caller)) => row == caller,
_ => false,
};
Self {
id: s.id(),
user_id: s.user_id(),
created_at: s.created_at(),
expires_at: s.expires_at(),
ip_address: s.ip_address().map(str::to_owned),
user_agent: s.user_agent().map(str::to_owned),
is_bound: jkt.is_some(),
dpop_jkt_prefix,
is_revoked,
is_active: !is_revoked && !is_expired,
origin: s.origin(),
is_current,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
fn base(revoked: bool, jkt: Option<&str>) -> Session {
let mut s = Session::new(
Uuid::new_v4(),
"refresh-token".to_string(),
Some("192.0.2.1".to_string()),
Some("Mozilla/5.0".to_string()),
30,
Uuid::new_v4(),
crate::domain::entities::session::SessionOrigin::Password,
);
if revoked {
s.revoke();
}
if let Some(k) = jkt {
s = s.with_dpop_jkt(k.to_string());
}
s
}
fn oidc_session(sid: &str) -> Session {
Session::from_raw(
Uuid::new_v4(),
Uuid::new_v4(),
"rt".to_string(),
Utc::now() + Duration::days(30),
None,
None,
Utc::now(),
false,
Uuid::new_v4(),
Some("dummy.id.token".to_string()),
Some(sid.to_string()),
None,
crate::domain::entities::session::SessionOrigin::Oidc,
)
}
#[test]
fn dto_never_leaks_oidc_sid() {
// The IdP-emitted `sid` uniquely correlates the row to a real
// user's live IdP session and MUST stay server-side (used by
// Back-Channel Logout matching, never useful to an admin
// viewing sessions). Not even a prefix — see the DTO comment.
let full_sid = "8aa711b3-7438-cb35-4089-71a202e12285";
let dto = SessionSummaryDto::from(oidc_session(full_sid));
let json = serde_json::to_string(&dto).unwrap();
assert!(
!json.contains(full_sid),
"oidc_sid must never appear in the wire shape (not even a prefix): {json}"
);
assert!(
!json.contains("oidc_sid"),
"the `oidc_sid` key MUST NOT appear in the DTO shape: {json}"
);
}
#[test]
fn dto_never_leaks_oidc_id_token() {
// The id_token itself must NEVER surface — it's a JWT carrying
// user claims + a valid `id_token_hint` for RP-initiated logout.
let dto = SessionSummaryDto::from(oidc_session("sid-1"));
let json = serde_json::to_string(&dto).unwrap();
assert!(
!json.contains("dummy.id.token"),
"oidc_id_token must never appear in the wire shape: {json}"
);
}
#[test]
fn dto_never_leaks_refresh_token() {
let s = base(false, None);
let dto = SessionSummaryDto::from(s);
let json = serde_json::to_string(&dto).unwrap();
assert!(
!json.contains("refresh-token"),
"refresh_token must never appear in the wire shape"
);
}
#[test]
fn dto_truncates_dpop_jkt_to_8_chars() {
// 44-char base64url thumbprint (SHA-256 → 32 bytes → ceil(32/3)*4 = 44)
let full = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGH";
let dto = SessionSummaryDto::from(base(false, Some(full)));
assert_eq!(dto.dpop_jkt_prefix.as_deref(), Some("abcdefgh"));
assert!(dto.is_bound);
}
#[test]
fn dto_unbound_session_has_no_prefix() {
let dto = SessionSummaryDto::from(base(false, None));
assert_eq!(dto.dpop_jkt_prefix, None);
assert!(!dto.is_bound);
}
#[test]
fn is_active_false_when_revoked() {
let dto = SessionSummaryDto::from(base(true, None));
assert!(dto.is_revoked);
assert!(!dto.is_active);
}
#[test]
fn is_active_true_for_fresh_unrevoked_session() {
let dto = SessionSummaryDto::from(base(false, Some("jkt-abc")));
assert!(!dto.is_revoked);
assert!(dto.is_active);
}
#[test]
fn from_raw_expired_session_is_not_active() {
let past = Utc::now() - Duration::days(1);
let s = Session::from_raw(
Uuid::new_v4(),
Uuid::new_v4(),
"rt".to_string(),
past,
None,
None,
past,
false,
Uuid::new_v4(),
None,
None,
None,
crate::domain::entities::session::SessionOrigin::Unknown,
);
let dto = SessionSummaryDto::from(s);
assert!(!dto.is_active);
assert!(!dto.is_revoked); // exp-but-unrevoked distinct from revoked
}
}
+13
View File
@@ -118,6 +118,19 @@ pub struct ListUsersQueryDto {
pub summary: Option<bool>,
}
/// Query parameters for the admin sessions listing.
///
/// `user_id` is a String (not `Uuid`) because bad UUIDs need a clean
/// 400 response — the handler parses and rejects malformed input.
/// `include_revoked` defaults to `false` at the handler layer.
#[derive(Debug, Serialize, Deserialize)]
pub struct ListSessionsQueryDto {
pub user_id: Option<String>,
pub include_revoked: Option<bool>,
pub limit: Option<i64>,
pub offset: Option<i64>,
}
/// One row of the dashboard's quota panel — usage aggregate for a
/// single drive kind. Unlimited caps are excluded from `capped_quota_bytes`
/// and counted in `unlimited_count` so the panel can render the ratio
+35
View File
@@ -137,6 +137,21 @@ pub struct UserDto {
/// need to surface per-user credential state.
#[serde(default)]
pub has_password: bool,
/// TRUE when the caller's current session carries a DPoP JWK
/// thumbprint (`session.dpop_jkt IS NOT NULL`). Sourced from the
/// caller's JWT `cnf.jkt` claim — `is_some()` means the session
/// was bound at token-mint time.
///
/// Populated only by the `/api/auth/me` handler; other UserDto
/// emitters leave it `false`. The SPA reads this on `session.load()`
/// to skip a redundant `POST /api/auth/dpop/bind` call when the
/// session is already bound (which would 409 and log noisily under
/// the audit stream — see the `already_bound` reject). Only the
/// OIDC / magic-link redirect flows land here as `false` on first
/// visit; password login binds at session-mint time so the very
/// first `/me` after login already reports `true`.
#[serde(default)]
pub is_dpop_bound: bool,
}
/// Compact row returned by the paginated admin user table.
@@ -264,6 +279,11 @@ impl From<User> for UserDto {
// not a general user attribute.
force_password_change: false,
has_password,
// Populated only by `/api/auth/me` — the handler overlays
// the caller's session's actual DPoP binding state after
// this `From<User>` runs. Other UserDto emitters leave
// this at `false` (they lack session context).
is_dpop_bound: false,
}
}
}
@@ -279,6 +299,13 @@ pub struct LoginDto {
/// typed in the "Username or email" field as-is.
pub username: String,
pub password: String,
/// DPoP JWK thumbprint the client generated at page load. When
/// present, binds the new session to a browser-held keypair so
/// stealing the cookie without the private key is useless (RFC
/// 9449). Absent → session is created unbound (fail-open per the
/// `docs/plan/dpop.md` threat model). Malformed → 400.
#[serde(default, rename = "dpop_jkt", alias = "dpopJkt")]
pub dpop_jkt: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
@@ -449,6 +476,14 @@ pub struct CurrentUser {
pub email: Arc<str>,
#[schema(value_type = String)]
pub role: SmolStr,
/// DPoP session-binding thumbprint threaded from the JWT's
/// RFC 9449 §5 `cnf.jkt` claim. `None` for unbound sessions
/// (app passwords, NC clients, pre-DPoP). The DPoP middleware
/// reads it to enforce "bound → proof required" from an
/// already-validated token — no session-row lookup on the
/// hot path (see `docs/plan/dpop.md` Gate 9).
#[serde(skip)]
pub dpop_jkt: Option<String>,
}
// ============================================================================
+44 -2
View File
@@ -57,6 +57,12 @@ pub struct TokenClaims {
pub email: Arc<str>,
/// User role
pub role: String,
/// RFC 9449 §5 confirmation-key thumbprint — the JWK thumbprint
/// of the DPoP keypair this session was bound to at login. `None`
/// for unbound sessions (app passwords, NC clients, pre-DPoP).
/// The DPoP middleware reads it from the already-validated token
/// (no DB round trip) to enforce "bound session → proof required".
pub dpop_jkt: Option<String>,
}
/// Port for JWT token operations.
@@ -64,8 +70,19 @@ pub struct TokenClaims {
/// This trait abstracts token generation and validation, allowing the domain
/// layer to remain independent of specific JWT implementations.
pub trait TokenServicePort: Send + Sync + 'static {
/// Generate an access token for a user
fn generate_access_token(&self, user: &User) -> Result<String, DomainError>;
/// Generate an access token for a user.
///
/// `dpop_jkt` — if `Some`, the token carries an RFC 9449 §5
/// `cnf.jkt` claim binding it to the browser-held keypair whose
/// public JWK hashes to this thumbprint. Callers pass
/// `session.dpop_jkt()` from the Session being minted; unbound
/// sessions (app passwords, NC clients, pre-DPoP) pass `None`
/// and get a plain token the middleware exempts from DPoP.
fn generate_access_token(
&self,
user: &User,
dpop_jkt: Option<&str>,
) -> Result<String, DomainError>;
/// Validate a token and extract its claims.
///
@@ -473,6 +490,31 @@ pub trait SessionStoragePort: Send + Sync + 'static {
issuer: &str,
subject: &str,
) -> Result<Option<Uuid>, DomainError>;
/// One-shot bind a DPoP JWK thumbprint to a session that was created
/// without one (post-redirect flow — OIDC callback, magic-link
/// redemption). Fails with `AlreadyExists` if the session already
/// carries a thumbprint (anti-downgrade invariant, see
/// `docs/plan/dpop.md`).
async fn bind_dpop_jkt(&self, session_id: Uuid, dpop_jkt: &str) -> Result<(), DomainError>;
/// Fetch a single session by id. Used by admin surfaces that need
/// to resolve `target_user_id` for audit lines before a mutation.
/// Returns `NotFound` when the id doesn't match any row.
async fn get_session_by_id(&self, session_id: Uuid) -> Result<Session, DomainError>;
/// Paginated cross-user listing for the admin sessions panel.
/// `user_id_filter` narrows to a single user when `Some`; `None`
/// spans all users. `include_revoked = false` (the default UX)
/// returns only rows where `revoked = false AND expires_at > NOW()`.
/// Ordered newest first (`created_at DESC`).
async fn list_sessions_paginated(
&self,
user_id_filter: Option<Uuid>,
include_revoked: bool,
limit: i64,
offset: i64,
) -> Result<Vec<Session>, DomainError>;
}
// ============================================================================
@@ -28,6 +28,65 @@ use std::sync::RwLock;
use std::time::Duration;
use uuid::Uuid;
/// Validate a client-supplied DPoP JWK thumbprint. RFC 7638 §3 produces
/// a base64url-encoded SHA-256 (32 bytes → 43 base64url chars, no
/// padding). We accept exactly that shape; anything else is a client
/// bug or forgery attempt and gets rejected at the login boundary.
///
/// Returned string is the exact input on success — we don't
/// canonicalise the thumbprint further (it IS the canonical form).
fn validate_dpop_jkt(raw: &str) -> Result<String, &'static str> {
if raw.len() != 43 {
return Err("DPoP thumbprint must be 43 characters (base64url SHA-256)");
}
if !raw
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
{
return Err("DPoP thumbprint contains non-base64url characters");
}
Ok(raw.to_string())
}
#[cfg(test)]
mod dpop_jkt_tests {
use super::validate_dpop_jkt;
#[test]
fn accepts_well_formed_thumbprint() {
// 43 base64url chars — a real SHA-256 output shape
let jkt = "AbCdEfGhIjKlMnOpQrStUvWxYz0123456789-_ABCDE";
assert_eq!(validate_dpop_jkt(jkt).unwrap(), jkt);
}
#[test]
fn rejects_wrong_length() {
assert!(validate_dpop_jkt("").is_err());
assert!(validate_dpop_jkt("too-short").is_err());
assert!(
validate_dpop_jkt(&"a".repeat(44)).is_err(),
"44 chars must be rejected"
);
}
#[test]
fn rejects_padding() {
// 43-char string ending in `=` is still 43 chars but invalid
// base64url (padding never appears in URL_SAFE_NO_PAD).
let with_pad = format!("{}{}", "a".repeat(42), "=");
assert!(validate_dpop_jkt(&with_pad).is_err());
}
#[test]
fn rejects_standard_base64_alphabet() {
// `+` and `/` are standard base64 — url-safe uses `-` and `_`
let with_plus = format!("{}+", "a".repeat(42));
let with_slash = format!("{}/", "a".repeat(42));
assert!(validate_dpop_jkt(&with_plus).is_err());
assert!(validate_dpop_jkt(&with_slash).is_err());
}
}
/// Result of a successful OIDC callback. The handler layer inspects this to
/// decide whether to redirect to the regular frontend or complete a Nextcloud
/// Login Flow v2 session.
@@ -744,7 +803,12 @@ impl AuthApplicationService {
Ok(UserDto::from(created_user))
}
pub async fn login(&self, dto: LoginDto) -> Result<AuthResponseDto, DomainError> {
pub async fn login(
&self,
dto: LoginDto,
client_ip: Option<String>,
user_agent: Option<String>,
) -> Result<AuthResponseDto, DomainError> {
// Gate: policy may forbid password logins entirely (either the
// legacy OIDC-only mode or the newer `OXICLOUD_AUTH_METHODS`
// allowlist without `password`). Refuse BEFORE the user lookup
@@ -947,7 +1011,14 @@ impl AuthApplicationService {
// handshake (Phase 1, `login/ke3`). Both paths converge here
// so lifecycle + token + session-family semantics stay in
// one place.
self.mint_session_for_authenticated_user(user).await
self.mint_session_for_authenticated_user(
user,
dto.dpop_jkt,
client_ip,
user_agent,
crate::domain::entities::session::SessionOrigin::Password,
)
.await
}
/// Emit a fresh session for a user who has ALREADY been
@@ -973,6 +1044,10 @@ impl AuthApplicationService {
pub async fn mint_session_for_authenticated_user(
&self,
mut user: crate::domain::entities::user::User,
dpop_jkt: Option<String>,
client_ip: Option<String>,
user_agent: Option<String>,
origin: crate::domain::entities::session::SessionOrigin,
) -> Result<AuthResponseDto, DomainError> {
// Lifecycle: dispatch login BEFORE register_login() so hooks
// observing `last_login_at().is_none()` see "first ever login"
@@ -990,20 +1065,71 @@ impl AuthApplicationService {
// (benches/ROUND12.md §2, 4.45x).
user.register_login();
// Generate tokens using the injected token service
let access_token = self.token_service.generate_access_token(&user)?;
// Validate DPoP thumbprint FIRST — the same validated value
// has to flow into both the JWT `cnf.jkt` claim (RFC 9449 §5)
// and the session row's `dpop_jkt` column. Reject-before-mint
// avoids issuing a token whose confirmation-key would be
// rejected by the very next request's DPoP middleware.
let validated_jkt = match dpop_jkt.as_deref() {
Some(jkt) => Some(validate_dpop_jkt(jkt).map_err(|e| {
tracing::info!(
target: "audit",
event = "auth.dpop_bind_rejected",
reason = "malformed_thumbprint",
user_id = %user.id(),
"🔐 DPoP bind rejected: {}", e,
);
DomainError::new(
ErrorKind::InvalidInput,
"Auth",
"dpop_jkt must be a 43-character base64url SHA-256 thumbprint (RFC 7638)",
)
})?),
None => None,
};
// Generate tokens using the injected token service. The
// access token carries the `cnf.jkt` binding when present,
// so the DPoP middleware can enforce "bound → proof required"
// straight from the already-validated JWT — no session-row
// lookup on the hot path.
let access_token = self
.token_service
.generate_access_token(&user, validated_jkt.as_deref())?;
let refresh_token = self.token_service.generate_refresh_token();
// Save session — new login starts a new token family
let session = Session::new(
// Save session — new login starts a new token family. DPoP
// binding is set at INSERT time and immutable thereafter (see
// `docs/plan/dpop.md` — a mutable bind would let an attacker
// downgrade a bound session by re-binding to their own key).
let mut session = Session::new(
user.id(),
refresh_token.clone(),
None, // IP (can be added from the HTTP layer)
None, // User-Agent (can be added from the HTTP layer)
client_ip,
user_agent,
self.token_service.refresh_token_expiry_days(),
Uuid::new_v4(),
origin,
);
if let Some(jkt) = validated_jkt {
// Success-path audit — records the bind so operators can
// correlate a session_id in the panel with the exact moment
// it acquired its DPoP thumbprint. Emitted BEFORE the move
// (`with_dpop_jkt` consumes `jkt`) so the prefix is safe.
// See `docs/plan/dpop.md` §Gate 10.
let jkt_prefix: String = jkt.chars().take(8).collect();
tracing::info!(
target: "audit",
event = "dpop.bound_at_login",
session_id = %session.id(),
user_id = %session.user_id(),
origin = origin.as_str(),
jkt_prefix = %jkt_prefix,
"🔒 DPoP session bound at login"
);
session = session.with_dpop_jkt(jkt);
}
self.session_storage.create_session(session).await?;
@@ -1072,6 +1198,8 @@ impl AuthApplicationService {
token: &str,
incoming_challenge: Option<&str>,
cross_browser_confirmed: bool,
client_ip: Option<String>,
user_agent: Option<String>,
) -> Result<MagicLinkRedeemResult, DomainError> {
let repo = self.magic_link_repo.as_ref().ok_or_else(|| {
DomainError::new(
@@ -1249,15 +1377,21 @@ impl AuthApplicationService {
user.mark_email_verified();
self.user_storage.mark_email_verified(user.id()).await?;
let access_token = self.token_service.generate_access_token(&user)?;
// Magic-link redemption is a GET redirect — no way to
// thread `dpop_jkt` into a GET body. Session is minted
// unbound; the SPA calls `POST /api/auth/dpop/bind`
// post-redirect to bind it (see Gate 3). Token accordingly
// ships without `cnf.jkt`.
let access_token = self.token_service.generate_access_token(&user, None)?;
let refresh_token = self.token_service.generate_refresh_token();
let session = Session::new(
user.id(),
refresh_token.clone(),
None,
None,
client_ip,
user_agent,
self.token_service.refresh_token_expiry_days(),
Uuid::new_v4(),
crate::domain::entities::session::SessionOrigin::MagicLink,
);
self.session_storage.create_session(session).await?;
@@ -1335,12 +1469,19 @@ impl AuthApplicationService {
username: std::sync::Arc::from(user.username().unwrap_or("")),
email: std::sync::Arc::from(user.email()),
role: smol_str::SmolStr::new_static(user.role().as_str()),
// `verify_credentials` is only called from paths that
// don't need per-session DPoP context (admin/setup
// flows); the DPoP middleware never reads CurrentUser
// populated by this method. Leaving None is safe.
dpop_jkt: None,
})
}
pub async fn refresh_token(
&self,
dto: RefreshTokenDto,
client_ip: Option<String>,
user_agent: Option<String>,
) -> Result<AuthResponseDto, DomainError> {
// Get valid session
let session = self
@@ -1393,8 +1534,14 @@ impl AuthApplicationService {
));
}
// Generate new tokens
let access_token = self.token_service.generate_access_token(&user)?;
// Generate new tokens. Inherit the DPoP binding from the
// parent session so the refreshed access token carries the
// same `cnf.jkt` — otherwise every refresh would silently
// downgrade to unbound and the next request would 401 under
// Gate 9 enforcement (see Gate 7).
let access_token = self
.token_service
.generate_access_token(&user, session.dpop_jkt())?;
let new_refresh_token = self.token_service.generate_refresh_token();
// New session inherits the family_id so reuse of any ancestor triggers
@@ -1402,14 +1549,45 @@ impl AuthApplicationService {
// new one happen in ONE transaction (`rotate_session`) — this path
// used to pay two BEGIN/COMMIT pairs per refresh, and DAV clients
// rotate constantly (benches/ROUND12.md §4).
let new_session = Session::new(
//
// The DPoP binding travels with the family: if the parent session
// was bound to a browser-held keypair, the refreshed session MUST
// be bound to the same one (see `docs/plan/dpop.md` Gate 7). Same
// browser → same key → same jkt. Skipping this would let a
// refresh silently downgrade the session to unbound, and every
// subsequent request would fail DPoP verification once required
// mode enforces per-session binding.
let mut new_session = Session::new(
user.id(),
new_refresh_token.clone(),
None,
None,
client_ip,
user_agent,
self.token_service.refresh_token_expiry_days(),
session.family_id(),
// A rotation doesn't change how the user first authenticated,
// so origin is inherited from the parent row. Also keeps the
// admin panel's origin column stable across the natural
// refresh cycle apiFetch triggers on every 401.
session.origin(),
);
if let Some(jkt) = session.dpop_jkt() {
new_session = new_session.with_dpop_jkt(jkt.to_string());
}
// Carry over the OIDC provenance so RP-initiated logout still
// works after a refresh. Without this, an OIDC session rotates
// into a row with `oidc_id_token = NULL` on the very first
// refresh (which apiFetch triggers transparently on any 401),
// and the `/api/auth/logout` handler then has no `id_token_hint`
// to build the IdP's `end_session_endpoint` URL — user gets a
// local-only logout and stays signed in on the IdP.
// `oidc_sid` follows the same rule so Back-Channel Logout can
// still target this device via the IdP's sid claim after refresh.
if let Some(id_token) = session.oidc_id_token() {
new_session = new_session.with_oidc_id_token(id_token.to_string());
}
if let Some(sid) = session.oidc_sid() {
new_session = new_session.with_oidc_sid(sid.to_string());
}
self.session_storage
.rotate_session(session.id(), new_session)
@@ -2153,6 +2331,77 @@ impl AuthApplicationService {
}
}
/// Bind a DPoP JWK thumbprint to an EXISTING session — the
/// post-redirect path for OIDC and magic-link, whose redemptions
/// are GET requests and can't thread the thumbprint through the
/// login body. The SPA calls this once, immediately after the
/// redirect lands, with the thumbprint it generated at page load.
///
/// Emits `auth.dpop_bind_rejected` on validation failure or when
/// the caller tries to re-bind an already-bound session (anti-
/// downgrade guard). Emits `dpop.bound_at_login` (with
/// `method = "post_redirect_bind"`) on the accept path so operators
/// can correlate binding events with sessions — same event name as
/// the POST-flow bind at `mint_session_for_authenticated_user`, per
/// `docs/plan/dpop.md` §Gate 10.
pub async fn bind_dpop_jkt_to_session(
&self,
session_id: Uuid,
dpop_jkt: &str,
) -> Result<(), DomainError> {
let validated = validate_dpop_jkt(dpop_jkt).map_err(|e| {
tracing::info!(
target: "audit",
event = "auth.dpop_bind_rejected",
reason = "malformed_thumbprint",
session_id = %session_id,
"🔐 DPoP bind rejected: {}", e,
);
DomainError::new(
ErrorKind::InvalidInput,
"Auth",
"dpop_jkt must be a 43-character base64url SHA-256 thumbprint (RFC 7638)",
)
})?;
match self
.session_storage
.bind_dpop_jkt(session_id, &validated)
.await
{
Ok(()) => {
// Same event as the POST-flow bind at
// `mint_session_for_authenticated_user`; `method` is the
// constant `"post_redirect_bind"` because this path is
// exclusively OIDC-callback + magic-link redemption (both
// create the session unbound and hand off to this
// endpoint post-redirect — see `docs/plan/dpop.md`
// Gate 3). Consumers can `origin`-join with the session
// row to disambiguate if they need finer granularity.
let jkt_prefix: String = validated.chars().take(8).collect();
tracing::info!(
target: "audit",
event = "dpop.bound_at_login",
session_id = %session_id,
method = "post_redirect_bind",
jkt_prefix = %jkt_prefix,
"🔒 DPoP session bound at login (post-redirect)"
);
Ok(())
}
Err(e) if e.kind == ErrorKind::AlreadyExists => {
tracing::info!(
target: "audit",
event = "auth.dpop_bind_rejected",
reason = "already_bound",
session_id = %session_id,
"🔐 DPoP bind rejected: session already bound",
);
Err(e)
}
Err(e) => Err(e),
}
}
pub async fn get_user_flags(&self, user_id: Uuid) -> Result<UserFlags, DomainError> {
// Single-flight: concurrent misses for the same user coalesce
// into ONE storage lookup; errors are never cached (same herd
@@ -2751,6 +3000,83 @@ impl AuthApplicationService {
Ok(names.into_iter().flatten().collect())
}
// ========================================================================
// Admin Session Management Methods
// ========================================================================
//
// AuthZ posture: /api/admin/* is already protected by a
// `require_admin` router layer (see
// `interfaces/api/routes.rs::admin_router`) — but every admin
// method here still calls `require_admin_caller` as a
// defense-in-depth check, matching the pattern
// `list_users_including_external_with_perms` established. If a
// handler is ever wired outside the /admin subtree, the AuthZ
// still holds.
/// List sessions for the admin panel. `user_id_filter = Some(uuid)`
/// narrows to one user; `None` returns cross-user. `include_revoked`
/// controls whether to show revoked / expired rows — default UX
/// hides them (checkbox to opt in for forensics).
pub async fn admin_list_sessions_with_perms<A: AuthorizationEngine>(
&self,
authorization: &A,
caller: crate::application::dtos::session_dto::SessionCaller<'_>,
user_id_filter: Option<Uuid>,
include_revoked: bool,
limit: i64,
offset: i64,
) -> Result<Vec<crate::application::dtos::session_dto::SessionSummaryDto>, DomainError> {
self.require_admin_caller(authorization, caller.id).await?;
let sessions = self
.session_storage
.list_sessions_paginated(user_id_filter, include_revoked, limit, offset)
.await?;
Ok(sessions
.into_iter()
.map(|s| {
crate::application::dtos::session_dto::SessionSummaryDto::from_session(
s,
caller.dpop_jkt,
)
})
.collect())
}
/// Admin-driven session revocation. Sets `revoked = true` — the
/// row remains for audit visibility, but its refresh token is
/// dead and the next access-token refresh 401s naturally.
///
/// Emits an audit line + counter increment so operators can trace
/// who killed which session and when.
pub async fn admin_revoke_session_with_perms<A: AuthorizationEngine>(
&self,
authorization: &A,
caller: crate::application::dtos::session_dto::SessionCaller<'_>,
session_id: Uuid,
) -> Result<(), DomainError> {
self.require_admin_caller(authorization, caller.id).await?;
// Resolve target user for the audit line before revocation —
// once the session row is revoked the user_id is still readable
// but the ORDER is stable this way.
let target_user_id = self
.session_storage
.get_session_by_id(session_id)
.await
.ok()
.map(|s| s.user_id());
self.session_storage.revoke_session(session_id).await?;
tracing::info!(
target: "audit",
event = "admin.session_revoked",
caller_id = %caller.id,
session_id = %session_id,
target_user_id = target_user_id.map(|u| u.to_string()).unwrap_or_default(),
"👮🏻‍♂️ Admin revoked session",
);
metrics::counter!("oxicloud_admin_session_revoked_total").increment(1);
Ok(())
}
// ========================================================================
// Admin User Management Methods
// ========================================================================
@@ -3573,6 +3899,8 @@ impl AuthApplicationService {
code: &str,
state: &str,
locale_registry: &crate::common::locale::LocaleRegistry,
client_ip: Option<String>,
user_agent: Option<String>,
) -> Result<OidcCallbackResult, DomainError> {
// 0. Validate CSRF state and retrieve PKCE verifier + nonce + optional NC token
// (entry is auto-expired by moka TTL — remove returns None if expired)
@@ -4084,17 +4412,23 @@ impl AuthApplicationService {
});
}
// 6. Issue internal tokens (same as regular login)
let access_token = self.token_service.generate_access_token(&user)?;
// 6. Issue internal tokens (same as regular login). OIDC
// callback is a GET redirect — no way to thread `dpop_jkt`
// through the browser's redirect chain. Session is minted
// unbound; the SPA calls `POST /api/auth/dpop/bind` post-
// redirect to bind it (see Gate 3). Token accordingly ships
// without `cnf.jkt`.
let access_token = self.token_service.generate_access_token(&user, None)?;
let refresh_token = self.token_service.generate_refresh_token();
let mut session = Session::new(
user.id(),
refresh_token.clone(),
None,
None,
client_ip,
user_agent,
self.token_service.refresh_token_expiry_days(),
Uuid::new_v4(),
crate::domain::entities::session::SessionOrigin::Oidc,
)
.with_oidc_id_token(token_set.id_token.clone());
// Bind the IdP's session identifier so Back-Channel Logout can
@@ -4307,10 +4641,15 @@ mod phase4_gate_integration_tests {
let user_id = seed_user_with_password(&pool, &hasher, &email, "s3cret-passphrase").await;
// Baseline — no envelope, no migration mark → legacy works.
svc.login(crate::application::dtos::user_dto::LoginDto {
username: email.clone(),
password: "s3cret-passphrase".to_string(),
})
svc.login(
crate::application::dtos::user_dto::LoginDto {
username: email.clone(),
password: "s3cret-passphrase".to_string(),
dpop_jkt: None,
},
None,
None,
)
.await
.expect("baseline legacy login must succeed");
@@ -4324,10 +4663,15 @@ mod phase4_gate_integration_tests {
// but AccessDenied with the exact message the handler layer
// remaps to `403 OpaqueLoginRequired`.
let refused = svc
.login(crate::application::dtos::user_dto::LoginDto {
username: email.clone(),
password: "s3cret-passphrase".to_string(),
})
.login(
crate::application::dtos::user_dto::LoginDto {
username: email.clone(),
password: "s3cret-passphrase".to_string(),
dpop_jkt: None,
},
None,
None,
)
.await
.expect_err("legacy login must be refused post-migration");
assert_eq!(
@@ -4347,10 +4691,15 @@ mod phase4_gate_integration_tests {
// password check specifically so an attacker without the
// password learns nothing about migration state.
let wrong = svc
.login(crate::application::dtos::user_dto::LoginDto {
username: email.clone(),
password: "wrong-password".to_string(),
})
.login(
crate::application::dtos::user_dto::LoginDto {
username: email.clone(),
password: "wrong-password".to_string(),
dpop_jkt: None,
},
None,
None,
)
.await
.expect_err("wrong password must still fail");
assert_eq!(wrong.message, "Invalid credentials");
@@ -4363,10 +4712,15 @@ mod phase4_gate_integration_tests {
.clear_registration(user_id)
.await
.expect("clear registration");
svc.login(crate::application::dtos::user_dto::LoginDto {
username: email,
password: "s3cret-passphrase".to_string(),
})
svc.login(
crate::application::dtos::user_dto::LoginDto {
username: email,
password: "s3cret-passphrase".to_string(),
dpop_jkt: None,
},
None,
None,
)
.await
.expect("legacy login must succeed again after admin clear_registration");
}
@@ -175,8 +175,12 @@ impl DeviceAuthService {
// Fetch user to generate tokens
let user = self.user_storage.get_user_by_id(user_id).await?;
// Generate internal JWT access token + refresh token
let access_token = self.token_service.generate_access_token(&user)?;
// Generate internal JWT access token + refresh token. Device-
// authorization sessions (RFC 8628) are for CLI / TV / device
// clients that don't run WebCrypto — always unbound (`None`
// for the `dpop_jkt` param), which the DPoP middleware exempts
// from proof requirements. See `docs/plan/dpop.md` Gate 9.
let access_token = self.token_service.generate_access_token(&user, None)?;
let refresh_token = self.token_service.generate_refresh_token();
// Persist refresh token as a session
@@ -187,6 +191,7 @@ impl DeviceAuthService {
Some(format!("device:{}", dc.client_name())), // user_agent
self.token_service.refresh_token_expiry_days(),
Uuid::new_v4(),
crate::domain::entities::session::SessionOrigin::Device,
);
self.session_storage.create_session(session).await?;
+749
View File
@@ -0,0 +1,749 @@
//! DPoP wire-protocol test helper for the api-test suite.
//!
//! Hurl can't drive DPoP: every proof carries a fresh `jti`, a
//! current `iat`, an `htm`/`htu` matching the exact request, an
//! ES256 signature from a persistent browser-held keypair, and a
//! nonce threaded from the server's `DPoP-Nonce` response header.
//! A declarative `.hurl` template has no way to compute that per
//! request. Same problem OPAQUE has, same solution:
//! `opaque-hurl-helper.rs` (task #19) verifies OPAQUE end-to-end;
//! this binary does the same for DPoP.
//!
//! Invocation (from `tests/api/run.sh`):
//!
//! ```bash
//! OXICLOUD_DPOP_MODE=required
//! DPOP_HELPER_BASE_URL=$base_url \
//! DPOP_HELPER_USERNAME=$username \
//! DPOP_HELPER_PASSWORD=$password \
//! ./target/debug/dpop-hurl-helper
//! ```
//!
//! Exit codes:
//! * 0 — every scenario succeeded.
//! * 1 — any scenario failed; diagnostic on stderr.
//!
//! Scope: this binary covers the wire contract for the currently-
//! integrated slice — verifier + nonce + replay + middleware in
//! opportunistic mode. Scenarios requiring session-context binding
//! (bound-vs-unbound enforcement per `session.dpop_jkt`, refresh
//! continuity, thumbprint mismatch vs stored) are deferred until
//! Gate 7 threads session context through the middleware.
use base64::Engine as _;
use base64::engine::general_purpose::{STANDARD as B64, URL_SAFE_NO_PAD as B64_URL_NO_PAD};
use opaque_ke::{ClientLogin, ClientLoginFinishParameters, CredentialResponse};
use p256::ecdsa::signature::Signer;
use p256::ecdsa::{Signature, SigningKey};
use rand_core::OsRng as OpaqueRng;
use serde_json::json;
use sha2::{Digest, Sha256};
use std::process::ExitCode;
// Reuse the concrete ciphersuite the production `OpaqueService` uses —
// mismatching client + server suites would fail every handshake with
// a confusing error.
use oxicloud::infrastructure::services::opaque_service::OxiCloudSuite;
/// Server may emit URL_SAFE_NO_PAD *or* STANDARD base64; try both so
/// a future format flip doesn't silently break the round-trip.
fn decode_opaque_b64(input: &str) -> Result<Vec<u8>, base64::DecodeError> {
let s = input.trim();
B64_URL_NO_PAD.decode(s).or_else(|_| B64.decode(s))
}
#[derive(serde::Deserialize)]
struct OpaqueParamsResp {
enabled: bool,
#[serde(rename = "ciphersuiteVersion")]
_ciphersuite_version: i16,
ksf: OpaqueKsfParams,
}
#[derive(serde::Deserialize)]
struct OpaqueKsfParams {
#[serde(rename = "memoryKib")]
memory_kib: u32,
iterations: u32,
parallelism: u32,
}
#[derive(serde::Deserialize)]
struct OpaqueKe1Resp {
#[serde(rename = "exchangeId")]
exchange_id: String,
#[serde(rename = "loginResponse")]
login_response: String,
}
const EXIT_FAIL: u8 = 1;
fn env_or_fail(key: &str) -> String {
std::env::var(key).unwrap_or_else(|_| {
eprintln!("dpop-hurl-helper: required env var {key} unset");
std::process::exit(EXIT_FAIL as i32);
})
}
fn fail(msg: impl std::fmt::Display) -> ExitCode {
// Bordered banner so a failure stands out against the interleaved
// server audit log — the last line before this is usually the
// server-side reject that caused it, which visually blends in.
eprintln!();
eprintln!("┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓");
eprintln!("┃ dpop-hurl-helper: FAIL ┃");
eprintln!("┃ {msg}");
eprintln!("┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛");
ExitCode::from(EXIT_FAIL)
}
fn now_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn random_jti() -> String {
// 16 bytes of OS randomness, base64url'd — plenty of entropy for
// per-request uniqueness; the server's replay cache keys on this.
use p256::elliptic_curve::rand_core::{OsRng, RngCore};
let mut b = [0u8; 16];
OsRng.fill_bytes(&mut b);
B64_URL_NO_PAD.encode(b)
}
/// A persistent-across-scenarios keypair — simulates one browser
/// tab whose IndexedDB entry survives every scenario in this run.
struct KeyBundle {
signing_key: SigningKey,
jwk_x_b64: String,
jwk_y_b64: String,
}
impl KeyBundle {
fn fresh() -> Self {
// Deterministic-ish seed — this is a test tool, not a
// security surface. Skipping `rand`-dep to keep the binary
// dep footprint identical to what Gate 5 already added.
let mut bytes = [0u8; 32];
for (i, b) in bytes.iter_mut().enumerate() {
*b = ((i as u8).wrapping_mul(37)).wrapping_add(1);
}
let signing_key = SigningKey::from_bytes(&bytes.into()).expect("valid P-256 scalar");
let vkey = signing_key.verifying_key();
let enc = vkey.to_encoded_point(false);
Self {
signing_key,
jwk_x_b64: B64_URL_NO_PAD.encode(enc.x().unwrap()),
jwk_y_b64: B64_URL_NO_PAD.encode(enc.y().unwrap()),
}
}
}
/// Overrides for building malformed / tampered proofs, per scenario.
#[derive(Default, Clone)]
struct ProofOverrides<'a> {
override_alg: Option<&'a str>,
override_typ: Option<&'a str>,
override_htm: Option<&'a str>,
override_htu: Option<&'a str>,
stale_iat: bool,
/// If `Some`, use this exact string for the `jti` instead of a
/// fresh random one — the replay scenario needs to reuse it.
fixed_jti: Option<String>,
/// If `Some`, include this literal `nonce` claim (even if it's
/// wrong on purpose). If `None`, use whatever the caller
/// tracked from `DPoP-Nonce` response headers.
force_nonce: Option<String>,
}
/// Build + sign a DPoP proof against the given method + URL.
/// `nonce` is the current server-issued nonce (if any); `overrides`
/// let scenario code tamper with the proof shape.
fn build_proof(
keys: &KeyBundle,
method: &str,
url: &str,
nonce: Option<&str>,
overrides: &ProofOverrides<'_>,
) -> String {
let header = json!({
"typ": overrides.override_typ.unwrap_or("dpop+jwt"),
"alg": overrides.override_alg.unwrap_or("ES256"),
"jwk": {
"crv": "P-256",
"kty": "EC",
"x": keys.jwk_x_b64,
"y": keys.jwk_y_b64,
},
});
let iat = if overrides.stale_iat {
now_secs() - 10_000
} else {
now_secs()
};
let mut claims = json!({
"htm": overrides.override_htm.unwrap_or(method),
"htu": overrides.override_htu.map(str::to_owned).unwrap_or_else(|| canonical_htu(url)),
"iat": iat,
"jti": overrides.fixed_jti.clone().unwrap_or_else(random_jti),
});
let nonce_to_use = overrides.force_nonce.as_deref().or(nonce);
if let Some(n) = nonce_to_use {
claims.as_object_mut().unwrap().insert(
"nonce".to_string(),
serde_json::Value::String(n.to_string()),
);
}
let h_b64 = B64_URL_NO_PAD.encode(header.to_string());
let p_b64 = B64_URL_NO_PAD.encode(claims.to_string());
let signing_input = format!("{h_b64}.{p_b64}");
let sig: Signature = keys.signing_key.sign(signing_input.as_bytes());
let s_b64 = B64_URL_NO_PAD.encode(sig.to_bytes());
format!("{h_b64}.{p_b64}.{s_b64}")
}
fn canonical_htu(url: &str) -> String {
let u = reqwest::Url::parse(url).expect("valid URL for htu");
format!("{}://{}{}", u.scheme(), u.authority(), u.path())
}
/// Log in via OPAQUE, return the access + refresh tokens.
///
/// `opaque-hurl-helper` runs earlier in `tests/api/run.sh` and
/// mints the OPAQUE envelope for `admin`; from that point on the
/// account is migrated and legacy `POST /api/auth/login` refuses
/// with Phase-4 `opaque_migrated_use_opaque` (403). So this
/// helper drives the OPAQUE handshake directly — same ciphersuite
/// (`OxiCloudSuite`) and same `/params` KSF-fetch pattern as
/// `opaque-hurl-helper`. Long-term this is also what OPAQUE-only
/// mode (`docs/plan/opaque-only.md`) requires: legacy login is
/// on the way out entirely.
async fn opaque_login(
http: &reqwest::Client,
base: &str,
username: &str,
password: &str,
dpop_jkt: Option<&str>,
) -> Result<(String, String), String> {
// Fetch server params so client-side Argon2 matches. Per Phase B
// the envelope's OWN KSF is authoritative for that user (via
// `/login/lookup`), but for a test admin whose envelope was
// freshly minted by the OPAQUE helper the two match, so
// `/params` is sufficient here.
let params: OpaqueParamsResp = http
.get(format!("{base}/api/auth/opaque/params"))
.send()
.await
.map_err(|e| format!("opaque /params: {e}"))?
.json()
.await
.map_err(|e| format!("parse /params: {e}"))?;
if !params.enabled {
return Err("server reports OPAQUE disabled — did opaque-hurl-helper run first?".into());
}
let ksf_params = argon2::Params::new(
params.ksf.memory_kib,
params.ksf.iterations,
params.ksf.parallelism,
None,
)
.map_err(|e| format!("build Argon2 params: {e}"))?;
let ksf = argon2::Argon2::new(
argon2::Algorithm::Argon2id,
argon2::Version::V0x13,
ksf_params,
);
let mut rng = OpaqueRng;
// ── KE1 — public endpoint, no bearer ─────────────────────────────
let client_login = ClientLogin::<OxiCloudSuite>::start(&mut rng, password.as_bytes())
.map_err(|e| format!("ClientLogin::start: {e}"))?;
let ke1_body = json!({
"userIdentifier": username,
"startLoginRequest": B64.encode(client_login.message.serialize()),
});
let ke1_res = http
.post(format!("{base}/api/auth/opaque/login/ke1"))
.json(&ke1_body)
.send()
.await
.map_err(|e| format!("ke1 POST: {e}"))?;
if !ke1_res.status().is_success() {
return Err(format!("ke1 returned {}", ke1_res.status()));
}
let ke1: OpaqueKe1Resp = ke1_res
.json()
.await
.map_err(|e| format!("parse ke1: {e}"))?;
let cred_bytes =
decode_opaque_b64(&ke1.login_response).map_err(|e| format!("decode loginResponse: {e}"))?;
let cred_response = CredentialResponse::<OxiCloudSuite>::deserialize(&cred_bytes)
.map_err(|e| format!("deserialize CredentialResponse: {e}"))?;
// ── KE3 — finish + submit ─────────────────────────────────────────
let login_finish = client_login
.state
.finish(
password.as_bytes(),
cred_response,
ClientLoginFinishParameters::new(None, opaque_ke::Identifiers::default(), Some(&ksf)),
)
.map_err(|e| format!("ClientLogin::finish (bad password?): {e}"))?;
// URL_SAFE_NO_PAD on the wire — matches what the SPA sends and
// what the server's handler prefers (accepts both, but this is
// the canonical form).
// Include `dpopJkt` when set so the server binds the session
// to the browser-held (well, test-held) keypair via Gate 3.
// Downstream scenarios can then exercise bound-session paths.
let mut ke3_body = json!({
"exchangeId": ke1.exchange_id,
"finishLoginRequest": B64_URL_NO_PAD.encode(login_finish.message.serialize()),
});
if let Some(jkt) = dpop_jkt {
ke3_body
.as_object_mut()
.unwrap()
.insert("dpopJkt".to_string(), json!(jkt));
}
let ke3_res = http
.post(format!("{base}/api/auth/opaque/login/ke3"))
.json(&ke3_body)
.send()
.await
.map_err(|e| format!("ke3 POST: {e}"))?;
if !ke3_res.status().is_success() {
return Err(format!("ke3 returned {}", ke3_res.status()));
}
let body: serde_json::Value = ke3_res.json().await.map_err(|e| format!("ke3 body: {e}"))?;
let access = body["access_token"]
.as_str()
.ok_or("ke3 response missing access_token")?
.to_string();
let refresh = body["refresh_token"]
.as_str()
.ok_or("ke3 response missing refresh_token")?
.to_string();
Ok((access, refresh))
}
/// Send a GET request to `path` with a DPoP proof, following the
/// nonce-challenge retry loop. Returns the final response + the
/// nonce currently cached (which the caller threads into follow-up
/// requests).
async fn get_with_dpop(
http: &reqwest::Client,
base: &str,
path: &str,
access_token: &str,
keys: &KeyBundle,
cached_nonce: Option<String>,
overrides: &ProofOverrides<'_>,
) -> Result<(reqwest::Response, Option<String>), String> {
let url = format!("{base}{path}");
let proof = build_proof(keys, "GET", &url, cached_nonce.as_deref(), overrides);
let res = http
.get(&url)
.bearer_auth(access_token)
.header("DPoP", proof)
.send()
.await
.map_err(|e| format!("GET {path}: {e}"))?;
// Harvest DPoP-Nonce even on failure — the server stamps it
// regardless so the client can retry with the fresh value.
let updated_nonce = res
.headers()
.get("DPoP-Nonce")
.and_then(|v| v.to_str().ok())
.map(str::to_owned)
.or(cached_nonce);
// Challenge-retry once — mirrors the SPA fetch interceptor.
if res.status() == 401
&& res
.headers()
.get("WWW-Authenticate")
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.contains("use_dpop_nonce"))
{
// Only ONE retry — a second challenge on the retry is a
// server bug and should surface as-is.
let proof2 = build_proof(keys, "GET", &url, updated_nonce.as_deref(), overrides);
let res2 = http
.get(&url)
.bearer_auth(access_token)
.header("DPoP", proof2)
.send()
.await
.map_err(|e| format!("GET {path} (retry): {e}"))?;
let updated_nonce2 = res2
.headers()
.get("DPoP-Nonce")
.and_then(|v| v.to_str().ok())
.map(str::to_owned)
.or(updated_nonce);
return Ok((res2, updated_nonce2));
}
Ok((res, updated_nonce))
}
fn expect_status(scenario: &str, res: &reqwest::Response, want: u16) -> Result<(), String> {
if res.status().as_u16() == want {
Ok(())
} else {
Err(format!(
"scenario {scenario}: expected HTTP {want}, got {}",
res.status()
))
}
}
// Defensive-programming pattern: every scenario refreshes the
// cached nonce so a later scenario picks up any rotation the
// server did in between. Scenarios that DON'T re-consume the
// cached nonce (e.g. #6 which passes a bogus value on purpose)
// look like dead assignments to the linter — silence it here
// rather than sprinkle `let _ = n` calls that obscure intent.
#[allow(unused_assignments)]
#[tokio::main(flavor = "current_thread")]
async fn main() -> ExitCode {
let base = env_or_fail("DPOP_HELPER_BASE_URL");
let username = env_or_fail("DPOP_HELPER_USERNAME");
let password = env_or_fail("DPOP_HELPER_PASSWORD");
let base = base.trim_end_matches('/');
let http = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
{
Ok(c) => c,
Err(e) => return fail(format!("build reqwest client: {e}")),
};
// ── 1. Mint the persistent keypair FIRST, compute its JWK
// thumbprint, then log in via OPAQUE passing that
// thumbprint so the resulting session is bound (Gate 3).
// That way subsequent scenarios exercise the bound-path
// enforcement Gate 9 lit up: bound session + missing
// proof → 401, wrong-jkt proof → 401 `jkt_mismatch`.
//
// OPAQUE (not legacy) because `opaque-hurl-helper`
// migrates `admin` earlier in `run.sh`, after which
// legacy login 403s with Phase-4 refusal — and once
// OPAQUE-only mode ships there IS no legacy path anyway.
let keys = KeyBundle::fresh();
let jkt = {
let canonical = format!(
r#"{{"crv":"P-256","kty":"EC","x":"{}","y":"{}"}}"#,
keys.jwk_x_b64, keys.jwk_y_b64
);
B64_URL_NO_PAD.encode(Sha256::digest(canonical.as_bytes()))
};
eprintln!("dpop-hurl-helper: keypair jkt={jkt}");
let (access, _refresh) = match opaque_login(&http, base, &username, &password, Some(&jkt)).await
{
Ok(t) => t,
Err(e) => return fail(e),
};
let mut nonce: Option<String> = None;
// ── Scenario 1: happy path — bootstrap (no nonce) → challenge
// → retry with nonce → 200. The retry loop is inside
// `get_with_dpop`.
let (res, n) = match get_with_dpop(
&http,
base,
"/api/auth/me",
&access,
&keys,
nonce.clone(),
&ProofOverrides::default(),
)
.await
{
Ok(t) => t,
Err(e) => return fail(e),
};
if let Err(e) = expect_status("happy_path", &res, 200) {
return fail(e);
}
if n.is_none() {
return fail("happy_path: server did not stamp DPoP-Nonce on response");
}
nonce = n;
eprintln!(
"dpop-hurl-helper: scenario 1 (happy path) ✓ cached_nonce={:?}",
nonce
);
// ── Scenario 2: wrong htm — sign for POST but send GET → 401
let (res, n) = match get_with_dpop(
&http,
base,
"/api/auth/me",
&access,
&keys,
nonce.clone(),
&ProofOverrides {
override_htm: Some("POST"),
..Default::default()
},
)
.await
{
Ok(t) => t,
Err(e) => return fail(e),
};
nonce = n;
if let Err(e) = expect_status("wrong_htm", &res, 401) {
return fail(e);
}
eprintln!("dpop-hurl-helper: scenario 2 (wrong htm) ✓");
// ── Scenario 3: wrong htu — sign for /api/foo but send /api/auth/me → 401
let (res, n) = match get_with_dpop(
&http,
base,
"/api/auth/me",
&access,
&keys,
nonce.clone(),
&ProofOverrides {
override_htu: Some("https://not-this-host.example/api/foo"),
..Default::default()
},
)
.await
{
Ok(t) => t,
Err(e) => return fail(e),
};
nonce = n;
if let Err(e) = expect_status("wrong_htu", &res, 401) {
return fail(e);
}
eprintln!("dpop-hurl-helper: scenario 3 (wrong htu) ✓");
// ── Scenario 4: wrong alg (RS256) — 401
let (res, n) = match get_with_dpop(
&http,
base,
"/api/auth/me",
&access,
&keys,
nonce.clone(),
&ProofOverrides {
override_alg: Some("RS256"),
..Default::default()
},
)
.await
{
Ok(t) => t,
Err(e) => return fail(e),
};
nonce = n;
if let Err(e) = expect_status("wrong_alg", &res, 401) {
return fail(e);
}
eprintln!("dpop-hurl-helper: scenario 4 (wrong alg) ✓");
// ── Scenario 5: wrong typ — 401
let (res, n) = match get_with_dpop(
&http,
base,
"/api/auth/me",
&access,
&keys,
nonce.clone(),
&ProofOverrides {
override_typ: Some("jwt"),
..Default::default()
},
)
.await
{
Ok(t) => t,
Err(e) => return fail(e),
};
nonce = n;
if let Err(e) = expect_status("wrong_typ", &res, 401) {
return fail(e);
}
eprintln!("dpop-hurl-helper: scenario 5 (wrong typ) ✓");
// ── Scenario 6: stale nonce — simulate the client's cached
// nonce having expired server-side (server restarted, or
// pool TTL elapsed). The server issues a challenge with a
// fresh nonce, and `get_with_dpop` MUST retry once using
// that fresh value from the response header — not the
// stale one from `cached_nonce`.
//
// Pass the bogus value positionally (via `cached_nonce`)
// rather than through the `force_nonce` override — the
// override would survive the retry and cause a second
// challenge, defeating the recovery path we're testing.
let (res, n) = match get_with_dpop(
&http,
base,
"/api/auth/me",
&access,
&keys,
Some("nonce-that-server-does-not-know".to_string()),
&ProofOverrides::default(),
)
.await
{
Ok(t) => t,
Err(e) => return fail(e),
};
if let Err(e) = expect_status("stale_nonce_challenge_retry", &res, 200) {
return fail(e);
}
nonce = n;
eprintln!("dpop-hurl-helper: scenario 6 (stale nonce → challenge → retry) ✓");
// ── Scenario 7: replay — build a proof, send it TWICE with
// the same jti; second call must be replay-rejected.
// Uses a bespoke send (no retry-loop) so we control both
// submissions of the exact-same bytes.
let fixed_jti = random_jti();
let url = format!("{base}/api/auth/me");
let proof_replay = build_proof(
&keys,
"GET",
&url,
nonce.as_deref(),
&ProofOverrides {
fixed_jti: Some(fixed_jti.clone()),
..Default::default()
},
);
let res_first = match http
.get(&url)
.bearer_auth(&access)
.header("DPoP", proof_replay.clone())
.send()
.await
{
Ok(r) => r,
Err(e) => return fail(format!("replay send 1: {e}")),
};
if let Err(e) = expect_status("replay_first_send", &res_first, 200) {
return fail(e);
}
let res_second = match http
.get(&url)
.bearer_auth(&access)
.header("DPoP", proof_replay)
.send()
.await
{
Ok(r) => r,
Err(e) => return fail(format!("replay send 2: {e}")),
};
if let Err(e) = expect_status("replay_second_send", &res_second, 401) {
return fail(e);
}
// The replay 401 is an invalid_dpop_proof shape (not use_dpop_nonce)
let www_auth = res_second
.headers()
.get("WWW-Authenticate")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if www_auth.contains("use_dpop_nonce") {
return fail(format!(
"replay: WWW-Authenticate should be invalid_dpop_proof, was: {www_auth}"
));
}
eprintln!("dpop-hurl-helper: scenario 7 (replay) ✓");
// ── Scenario 8: malformed JWS — send garbage in the DPoP
// header. Server-side verifier rejects at the split step.
let res = match http
.get(&url)
.bearer_auth(&access)
.header("DPoP", "not-a-real-jws")
.send()
.await
{
Ok(r) => r,
Err(e) => return fail(format!("malformed send: {e}")),
};
if let Err(e) = expect_status("malformed", &res, 401) {
return fail(e);
}
eprintln!("dpop-hurl-helper: scenario 8 (malformed) ✓");
// ── Scenario 9: bound session with NO proof — Gate 9
// enforcement. Under `required` mode this must 401 with a
// `use_dpop_nonce` challenge (server treats missing proof
// on a bound session the same shape as a nonce-challenge
// to nudge the client back onto the DPoP path). Under
// `opportunistic` mode this would 200 with only a warning
// audit line. Test env pins `required` (see
// `tests/common/server.env`).
let res_no_proof = match http.get(&url).bearer_auth(&access).send().await {
Ok(r) => r,
Err(e) => return fail(format!("no_proof send: {e}")),
};
if let Err(e) = expect_status("no_proof_bound_session_required", &res_no_proof, 401) {
return fail(e);
}
eprintln!("dpop-hurl-helper: scenario 9 (no proof, bound session, required → 401) ✓");
// ── Scenario 10: bound session, valid proof shape but signed
// by a DIFFERENT keypair than the session was bound to.
// Verifier fires `jkt_mismatch` → 401. The classic attacker
// scenario: cookie stolen, attacker mints their own DPoP
// keypair, valid proof shape but wrong key.
let rogue_keys = KeyBundle {
signing_key: {
let mut bytes = [0u8; 32];
for (i, b) in bytes.iter_mut().enumerate() {
*b = ((i as u8).wrapping_mul(41)).wrapping_add(7);
}
SigningKey::from_bytes(&bytes.into()).expect("valid P-256 scalar")
},
jwk_x_b64: String::new(),
jwk_y_b64: String::new(),
};
// Rebuild x/y for the rogue key.
let rogue_enc = rogue_keys
.signing_key
.verifying_key()
.to_encoded_point(false);
let rogue_keys = KeyBundle {
signing_key: rogue_keys.signing_key,
jwk_x_b64: B64_URL_NO_PAD.encode(rogue_enc.x().unwrap()),
jwk_y_b64: B64_URL_NO_PAD.encode(rogue_enc.y().unwrap()),
};
let rogue_proof = build_proof(
&rogue_keys,
"GET",
&url,
nonce.as_deref(),
&ProofOverrides::default(),
);
let res_rogue = match http
.get(&url)
.bearer_auth(&access)
.header("DPoP", rogue_proof)
.send()
.await
{
Ok(r) => r,
Err(e) => return fail(format!("rogue-key send: {e}")),
};
if let Err(e) = expect_status("wrong_jkt_bound_session", &res_rogue, 401) {
return fail(e);
}
eprintln!("dpop-hurl-helper: scenario 10 (bound session + wrong-jkt proof → 401) ✓");
eprintln!("dpop-hurl-helper: all scenarios passed");
ExitCode::SUCCESS
}
+45 -1
View File
@@ -360,6 +360,50 @@ async fn main() -> ExitCode {
Err(e) => return fail(format!("/api/auth/me network: {e}")),
}
eprintln!("opaque-hurl-helper: OK — register + login + /me round-trip for '{username}'");
// SessionOrigin regression pin. The OPAQUE mint path funnels
// through `mint_session_for_authenticated_user(_, _, _, _,
// SessionOrigin::Opaque)`; a refactor that dropped that arg or
// wired the wrong variant would surface here as `unknown` (or
// any other origin) in the admin panel's row list. We can't
// check this from Hurl because /api/auth/login refuses migrated
// OPAQUE accounts (Phase 4 gate) — the OPAQUE-minted bearer is
// the ONLY credential this helper has access to at this point,
// so the assertion has to live in the same binary.
//
// No user_id filter needed: the test DB carries a single user
// (admin) at this stage, and `include_revoked=true` guarantees
// the OPAQUE row is in-frame even if a follow-up test has
// rotated it. Cheap substring check on the JSON body — we don't
// need to parse the array because "opaque" is a distinctive
// enough string that a false positive would require an
// origin-shaped `"opaque"` elsewhere in the wire payload, which
// the SessionSummaryDto shape rules out by construction.
match http
.get(format!(
"{base}/api/admin/sessions?include_revoked=true&limit=100"
))
.header("Authorization", format!("Bearer {}", auth.access_token))
.send()
.await
{
Ok(r) if r.status().is_success() => match r.text().await {
Ok(body) if body.contains("\"origin\":\"opaque\"") => {}
Ok(body) => {
return fail(format!(
"/api/admin/sessions: OPAQUE session not found in body — origin field missing or wrong variant. Body: {}",
&body[..body.len().min(512)]
));
}
Err(e) => return fail(format!("/api/admin/sessions body read: {e}")),
},
Ok(r) => {
return fail(format!("/api/admin/sessions: HTTP {}", r.status()));
}
Err(e) => return fail(format!("/api/admin/sessions network: {e}")),
}
eprintln!(
"opaque-hurl-helper: OK — register + login + /me + admin sessions origin=opaque for '{username}'"
);
ExitCode::from(EXIT_OK)
}
+86
View File
@@ -1448,6 +1448,55 @@ pub struct AuthConfig {
///
/// Env: `OXICLOUD_REQUIRE_VERIFIED_EMAIL` (default `false`).
pub require_verified_email: bool,
/// DPoP session-binding enforcement (RFC 9449). Bound sessions —
/// those created with a `dpop_jkt` supplied at login — carry a
/// browser-held keypair thumbprint; the middleware verifies a
/// per-request signed proof so that stealing the session cookie
/// alone is useless without the private key.
///
/// Modes (see `DpopMode` enum):
/// * `Off` (default) — middleware is a pass-through; no
/// verification even when a proof is present. Ship-safe
/// default while the client rollout catches up.
/// * `Opportunistic` — verify when a proof is present, reject
/// mismatches; skip when absent. Warn on
/// `dpop.header_missing_but_session_bound`. Rollout mode.
/// * `Required` — bound sessions MUST present a valid proof.
/// Unbound sessions (`dpop_jkt IS NULL` — app passwords,
/// Nextcloud clients, legacy) remain exempt at the
/// middleware level.
///
/// Env: `OXICLOUD_DPOP_MODE` in `{off,opportunistic,required}`
/// (default `off`).
pub dpop_mode: DpopMode,
}
/// DPoP session-binding enforcement mode. See `AuthConfig::dpop_mode`
/// and `docs/plan/dpop.md` for the rollout strategy.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DpopMode {
/// Middleware pass-through — DPoP header is neither required nor
/// verified. Default: safe while clients roll out proof-signing.
#[default]
Off,
/// Verify when present, allow when absent. Bound sessions still
/// get a warning audit line when they arrive without a proof.
Opportunistic,
/// Bound sessions (`dpop_jkt IS NOT NULL`) MUST present a valid
/// proof or 401. Unbound sessions remain exempt.
Required,
}
impl DpopMode {
pub fn from_env_str(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"off" => Some(Self::Off),
"opportunistic" => Some(Self::Opportunistic),
"required" => Some(Self::Required),
_ => None,
}
}
}
/// Self-service auth method. Exposed as `AuthConfig::allowed_auth_methods`
@@ -1583,6 +1632,7 @@ impl Default for AuthConfig {
auth_policies: Vec::new(),
allowed_auth_methods: vec![AuthMethod::Password, AuthMethod::MagicLink],
require_verified_email: false,
dpop_mode: DpopMode::Off,
}
}
}
@@ -2483,6 +2533,16 @@ pub struct AppConfig {
pub server_port: u16,
/// Server host
pub server_host: String,
/// Prometheus `/metrics` listener address, or `None` to disable.
///
/// Env: `OXICLOUD_METRICS_LISTEN` (e.g. `127.0.0.1:9090`).
/// Unset / empty = no metrics recorder is installed and no
/// `/metrics` endpoint is bound (default). When set, a separate
/// axum listener on this address exposes the text-format scrape
/// — deliberately NOT merged into the main API so operators can
/// bind to loopback / a private interface without exposing
/// metrics publicly.
pub metrics_listen: Option<std::net::SocketAddr>,
/// Cache configuration
pub cache: CacheConfig,
/// Timeout configuration
@@ -2610,6 +2670,7 @@ impl Default for AppConfig {
search_cache: SearchCacheConfig::default(),
plugins: PluginConfig::default(),
faces: FacesConfig::default(),
metrics_listen: None,
}
}
}
@@ -2641,6 +2702,22 @@ impl AppConfig {
config.server_host = server_host;
}
// Prometheus /metrics listener — opt-in, off by default. Empty
// string treated the same as unset (a common bare-word `=` shape
// in .env files). Parse failure is a fatal-shaped warning so
// operators don't silently ship without metrics they expected.
if let Ok(raw) = env::var("OXICLOUD_METRICS_LISTEN")
&& !raw.trim().is_empty()
{
match raw.parse::<std::net::SocketAddr>() {
Ok(addr) => config.metrics_listen = Some(addr),
Err(err) => tracing::warn!(
"OXICLOUD_METRICS_LISTEN={raw:?} is not a valid socket address ({err}) \
— metrics endpoint will NOT be exposed"
),
}
}
// Database configuration
if let Ok(connection_string) = env::var("OXICLOUD_DB_CONNECTION_STRING") {
config.database.connection_string = connection_string;
@@ -2978,6 +3055,15 @@ impl AppConfig {
}
}
if let Ok(v) = env::var("OXICLOUD_DPOP_MODE") {
match DpopMode::from_env_str(&v) {
Some(mode) => config.auth.dpop_mode = mode,
None => panic!(
"OXICLOUD_DPOP_MODE={v:?} — expected one of off / opportunistic / required"
),
}
}
if let Ok(v) = env::var("OXICLOUD_REQUIRE_VERIFIED_EMAIL") {
config.auth.require_verified_email = v.parse::<bool>().unwrap_or(false);
}
+53
View File
@@ -1271,6 +1271,36 @@ impl AppServiceFactory {
.await
}
/// Registers the session-cleanup janitor with the scheduler.
///
/// Purges rows in `auth.sessions` whose `expires_at` is older than
/// the janitor's retention window (currently 90 days, hardcoded —
/// see `SessionCleanupService::RETENTION_DAYS`). Runs on the
/// maintenance pool: the sweep is one bulk-DELETE per tick, but
/// keeping session-hygiene off the request pool matches every
/// other janitor in this file and prevents starvation surprises.
///
/// Fills the gap called out in
/// `[[project_session_janitor_missing]]` — `delete_expired_sessions`
/// (and its cutoff-taking sibling) existed on the repo but nothing
/// was scheduling them, so `auth.sessions` bloated forever.
pub async fn create_session_cleanup_service(
&self,
maintenance_pool: &Arc<PgPool>,
core: &CoreServices,
) -> Arc<crate::infrastructure::services::session_cleanup_service::SessionCleanupService> {
let session_repo = Arc::new(
crate::infrastructure::repositories::SessionPgRepository::new(maintenance_pool.clone()),
);
Arc::new(
crate::infrastructure::services::session_cleanup_service::SessionCleanupService::new(
session_repo,
),
)
.register(&core.job_registry)
.await
}
/// Starts the tree-ETag flush job (requires database).
///
/// The statement triggers on `storage.files`/`storage.folders` only
@@ -1709,6 +1739,13 @@ impl AppServiceFactory {
self.start_tree_etag_flush_job(&maintenance_pool);
// Session janitor — bulk-deletes `auth.sessions` rows past
// the retention window (90 days beyond `expires_at`). Runs
// once every 24h on the maintenance pool.
let _ = self
.create_session_cleanup_service(&maintenance_pool, &core)
.await;
self.start_db_pool_monitor(&pool);
self.start_content_index_job(&maintenance_pool, &core, content_index);
@@ -2102,6 +2139,12 @@ impl AppServiceFactory {
opaque_service,
opaque_repo,
opaque_login_exchange,
dpop_nonce_service: Arc::new(
crate::infrastructure::services::dpop_nonce_service::DpopNonceService::new(),
),
dpop_replay_cache: Arc::new(
crate::infrastructure::services::dpop_replay_cache::DpopReplayCache::new(),
),
nextcloud: nextcloud_services,
admin_settings_service: None,
storage_settings_service: None,
@@ -2874,6 +2917,16 @@ pub struct AppState {
pub opaque_login_exchange: Option<
Arc<crate::infrastructure::services::opaque_login_exchange::OpaqueLoginExchange>,
>,
/// DPoP nonce pool. Always populated (even in `dpop_mode = off`)
/// so switching mode via env-flip needs no restart-time wiring
/// change. Cheap-to-construct in-memory moka cache; unused paths
/// pay only allocation cost at boot.
pub dpop_nonce_service:
Arc<crate::infrastructure::services::dpop_nonce_service::DpopNonceService>,
/// DPoP replay cache — nonce-scoped `jti` dedup. Same lifecycle
/// as `dpop_nonce_service` (always populated, cheap at boot).
pub dpop_replay_cache:
Arc<crate::infrastructure::services::dpop_replay_cache::DpopReplayCache>,
pub nextcloud: Option<NextcloudServices>,
pub admin_settings_service: Option<Arc<AdminSettingsService>>,
/// WASM plugin management (list/install/toggle/remove), backing the admin
+177
View File
@@ -1,6 +1,66 @@
use chrono::{DateTime, Duration, Utc};
use uuid::Uuid;
/// How a session was originally minted. Set at INSERT by the login
/// handler; carried over on refresh (a rotation doesn't change how the
/// user first authenticated). Stored as `text` server-side with a CHECK
/// constraint — see `migrations/20261013000000_sessions_origin.sql`.
///
/// `serde(rename_all = "snake_case")` so the wire values match the
/// column values one-to-one: `password | opaque | magic_link | oidc |
/// unknown`. `Unknown` is the fallback for pre-migration rows and any
/// future login path that hasn't been taught to stamp an origin yet.
#[derive(
Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, utoipa::ToSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum SessionOrigin {
Password,
Opaque,
MagicLink,
Oidc,
/// RFC 8628 device authorization grant — CLI, TV apps, headless
/// clients that can't run a WebCrypto keypair. Always unbound at
/// the DPoP middleware. Distinct origin because admin operators
/// want to know "this login came from a headless device flow", not
/// conflate it with browser password entry.
Device,
Unknown,
}
impl SessionOrigin {
/// Wire / column string form. Kept out of `Display` to avoid
/// accidental use in log lines where the Debug form is fine.
pub fn as_str(self) -> &'static str {
match self {
Self::Password => "password",
Self::Opaque => "opaque",
Self::MagicLink => "magic_link",
Self::Oidc => "oidc",
Self::Device => "device",
Self::Unknown => "unknown",
}
}
/// Parse from the column / wire string. Any unrecognised value
/// maps to `Unknown` — matches the CHECK constraint's failure
/// mode (impossible on well-behaved writes, defensive on load).
/// Named `from_wire` (not `from_str`) to avoid shadowing the
/// standard `std::str::FromStr::from_str` trait method, which
/// would force us to pick a meaningless `Err` type when this
/// helper is intentionally infallible.
pub fn from_wire(s: &str) -> Self {
match s {
"password" => Self::Password,
"opaque" => Self::Opaque,
"magic_link" => Self::MagicLink,
"oidc" => Self::Oidc,
"device" => Self::Device,
_ => Self::Unknown,
}
}
}
#[derive(Debug, Clone)]
pub struct Session {
id: Uuid,
@@ -23,9 +83,24 @@ pub struct Session {
/// BCL notification would revoke all of the user's sessions rather
/// than just the one that logged out on the far end.
oidc_sid: Option<String>,
/// DPoP JWK thumbprint (RFC 7638, base64url-encoded SHA-256) binding
/// this session to a browser-held keypair. `None` for app-password
/// / Nextcloud-client / pre-DPoP / unbound sessions — the DPoP
/// middleware exempts them (see `docs/plan/dpop.md`).
///
/// Immutable per-session: set at construction time, never updated.
/// Downgrading a bound session by clearing the thumbprint would let
/// a stolen cookie replay without the private key — the whole point
/// of the binding is to prevent that.
dpop_jkt: Option<String>,
/// How this row was minted — see [`SessionOrigin`]. Required at
/// construction so a callsite can't forget to record it (the
/// admin sessions panel filters on this).
origin: SessionOrigin,
}
impl Session {
#[allow(clippy::too_many_arguments)]
pub fn new(
user_id: Uuid,
refresh_token: String,
@@ -33,6 +108,7 @@ impl Session {
user_agent: Option<String>,
expires_in_days: i64,
family_id: Uuid,
origin: SessionOrigin,
) -> Self {
if refresh_token.is_empty() {
panic!("Session refresh_token cannot be empty");
@@ -51,9 +127,26 @@ impl Session {
family_id,
oidc_id_token: None,
oidc_sid: None,
dpop_jkt: None,
origin,
}
}
/// Bind the session to a DPoP-Nonce browser keypair. Called by every
/// login handler when the client presented a well-formed thumbprint
/// in its login request. Absent → session stays unbound (fail-open).
///
/// Immutable once set: this method panics if called on a session
/// that already has a thumbprint, so a callsite mistake can't
/// silently overwrite the binding.
pub fn with_dpop_jkt(mut self, jkt: String) -> Self {
if self.dpop_jkt.is_some() {
panic!("Session.dpop_jkt is immutable — call at construction time only");
}
self.dpop_jkt = Some(jkt);
self
}
/// Attach an OIDC ID token — call on sessions minted via the OIDC exchange.
/// The token is persisted with the session and re-emitted at logout as
/// `id_token_hint` so the IdP can end its own SSO session.
@@ -85,6 +178,8 @@ impl Session {
family_id: Uuid,
oidc_id_token: Option<String>,
oidc_sid: Option<String>,
dpop_jkt: Option<String>,
origin: SessionOrigin,
) -> Self {
Self {
id,
@@ -98,6 +193,8 @@ impl Session {
family_id,
oidc_id_token,
oidc_sid,
dpop_jkt,
origin,
}
}
@@ -153,4 +250,84 @@ impl Session {
pub fn oidc_sid(&self) -> Option<&str> {
self.oidc_sid.as_deref()
}
pub fn dpop_jkt(&self) -> Option<&str> {
self.dpop_jkt.as_deref()
}
pub fn origin(&self) -> SessionOrigin {
self.origin
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fresh_session() -> Session {
Session::new(
Uuid::new_v4(),
"refresh-token".to_string(),
None,
None,
30,
Uuid::new_v4(),
SessionOrigin::Unknown,
)
}
#[test]
fn new_session_has_no_dpop_binding() {
assert_eq!(fresh_session().dpop_jkt(), None);
}
#[test]
fn with_dpop_jkt_stores_thumbprint() {
let s = fresh_session().with_dpop_jkt("abc123".to_string());
assert_eq!(s.dpop_jkt(), Some("abc123"));
}
#[test]
#[should_panic(expected = "Session.dpop_jkt is immutable")]
fn with_dpop_jkt_rejects_double_bind() {
fresh_session()
.with_dpop_jkt("first".to_string())
.with_dpop_jkt("second".to_string());
}
#[test]
fn from_raw_round_trips_dpop_jkt() {
let s = Session::from_raw(
Uuid::new_v4(),
Uuid::new_v4(),
"token".to_string(),
Utc::now(),
None,
None,
Utc::now(),
false,
Uuid::new_v4(),
None,
None,
Some("thumbprint-xyz".to_string()),
SessionOrigin::Unknown,
);
assert_eq!(s.dpop_jkt(), Some("thumbprint-xyz"));
}
#[test]
fn session_origin_round_trip_snake_case_strings() {
for o in [
SessionOrigin::Password,
SessionOrigin::Opaque,
SessionOrigin::MagicLink,
SessionOrigin::Oidc,
SessionOrigin::Device,
SessionOrigin::Unknown,
] {
assert_eq!(SessionOrigin::from_wire(o.as_str()), o);
}
// Unknown catches typos / drift-off-column-values.
assert_eq!(SessionOrigin::from_wire("bogus"), SessionOrigin::Unknown);
}
}
@@ -12,6 +12,13 @@ pub enum SessionRepositoryError {
#[error("Timeout error: {0}")]
Timeout(String),
/// Attempted to bind a DPoP thumbprint to a session that already
/// carries one. Immutable-per-session invariant (see
/// `docs/plan/dpop.md` — mutable bind would let an attacker
/// downgrade a bound session by binding to their own key).
#[error("Session already has a DPoP thumbprint")]
DpopAlreadyBound,
}
pub type SessionRepositoryResult<T> = Result<T, SessionRepositoryError>;
@@ -25,6 +32,11 @@ impl From<SessionRepositoryError> for DomainError {
DomainError::internal_error("Database", msg)
}
SessionRepositoryError::Timeout(msg) => DomainError::timeout("Database", msg),
SessionRepositoryError::DpopAlreadyBound => DomainError::new(
crate::common::errors::ErrorKind::AlreadyExists,
"Session",
"This session already has a DPoP thumbprint and cannot be re-bound",
),
}
}
}
@@ -46,6 +58,23 @@ pub trait SessionRepository: Send + Sync + 'static {
async fn get_sessions_by_user_id(&self, user_id: Uuid)
-> SessionRepositoryResult<Vec<Session>>;
/// Paginated listing for the admin sessions panel. Cross-user by
/// default; `user_id_filter = Some(uuid)` narrows to one user.
/// `include_revoked = false` (the default UX) filters to sessions
/// that are BOTH non-revoked AND non-expired — what an operator
/// would call "active right now". `include_revoked = true` shows
/// everything for incident forensics.
///
/// Ordered by `created_at DESC` — newest first, matching the
/// existing `get_sessions_by_user_id` convention.
async fn list_sessions_paginated(
&self,
user_id_filter: Option<Uuid>,
include_revoked: bool,
limit: i64,
offset: i64,
) -> SessionRepositoryResult<Vec<Session>>;
/// Revokes a specific session
async fn revoke_session(&self, session_id: Uuid) -> SessionRepositoryResult<()>;
@@ -95,4 +124,35 @@ pub trait SessionRepository: Send + Sync + 'static {
/// Deletes expired sessions
async fn delete_expired_sessions(&self) -> SessionRepositoryResult<u64>;
/// Purge session rows whose `expires_at` is strictly older than
/// `cutoff` — i.e. long-expired rows the janitor drops after a
/// forensic window past the natural expiry (per
/// [[project_session_janitor_missing]]). Distinct from
/// `delete_expired_sessions` so callers can pick a policy:
/// * `delete_expired_sessions` — everything past `NOW()`, aggressive
/// (currently unused — the janitor prefers the delayed variant so
/// ops have a trail before the row disappears);
/// * `delete_sessions_expired_before(NOW() - 3 months)` — keeps a
/// 3-month audit window, the shape `SessionCleanupService` runs
/// with. Returns the row count for the audit line.
async fn delete_sessions_expired_before(
&self,
cutoff: chrono::DateTime<chrono::Utc>,
) -> SessionRepositoryResult<u64>;
/// One-shot bind a DPoP JWK thumbprint (RFC 7638) to a session that
/// was created without one. Used by the post-redirect bind endpoint
/// (`POST /api/auth/dpop/bind`) for the OIDC and magic-link flows,
/// where the redemption is a GET and can't carry the thumbprint in
/// its request body.
///
/// Enforces the immutability invariant at the SQL level with a
/// `WHERE dpop_jkt IS NULL` guard: if the row already carries a
/// thumbprint the UPDATE affects zero rows and we return
/// [`SessionRepositoryError::DpopAlreadyBound`]. That's the anti-
/// downgrade guard from `docs/plan/dpop.md` — an attacker who has
/// stolen the cookie of a bound session cannot re-bind to their
/// own key.
async fn bind_dpop_jkt(&self, session_id: Uuid, dpop_jkt: &str) -> SessionRepositoryResult<()>;
}
@@ -53,9 +53,9 @@ impl SessionRepository for SessionPgRepository {
INSERT INTO auth.sessions (
id, user_id, refresh_token, expires_at,
ip_address, user_agent, created_at, revoked, family_id,
oidc_id_token, oidc_sid
oidc_id_token, oidc_sid, dpop_jkt, origin
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13
)
"#,
)
@@ -70,6 +70,8 @@ impl SessionRepository for SessionPgRepository {
.bind(session_clone.family_id())
.bind(session_clone.oidc_id_token())
.bind(session_clone.oidc_sid())
.bind(session_clone.dpop_jkt())
.bind(session_clone.origin().as_str())
.execute(&mut **tx)
.await
.map_err(Self::map_sqlx_error)?;
@@ -115,7 +117,7 @@ impl SessionRepository for SessionPgRepository {
SELECT
id, user_id, refresh_token, expires_at,
ip_address, user_agent, created_at, revoked, family_id,
oidc_id_token, oidc_sid
oidc_id_token, oidc_sid, dpop_jkt, origin
FROM auth.sessions
WHERE id = $1
"#,
@@ -137,6 +139,8 @@ impl SessionRepository for SessionPgRepository {
row.get("family_id"),
row.get("oidc_id_token"),
row.get("oidc_sid"),
row.get("dpop_jkt"),
crate::domain::entities::session::SessionOrigin::from_wire(row.get("origin")),
))
}
@@ -151,7 +155,7 @@ impl SessionRepository for SessionPgRepository {
SELECT
id, user_id, refresh_token, expires_at,
ip_address, user_agent, created_at, revoked, family_id,
oidc_id_token, oidc_sid
oidc_id_token, oidc_sid, dpop_jkt, origin
FROM auth.sessions
WHERE refresh_token = $1
"#,
@@ -173,6 +177,8 @@ impl SessionRepository for SessionPgRepository {
row.get("family_id"),
row.get("oidc_id_token"),
row.get("oidc_sid"),
row.get("dpop_jkt"),
crate::domain::entities::session::SessionOrigin::from_wire(row.get("origin")),
))
}
@@ -186,7 +192,7 @@ impl SessionRepository for SessionPgRepository {
SELECT
id, user_id, refresh_token, expires_at,
ip_address, user_agent, created_at, revoked, family_id,
oidc_id_token, oidc_sid
oidc_id_token, oidc_sid, dpop_jkt, origin
FROM auth.sessions
WHERE user_id = $1
ORDER BY created_at DESC
@@ -212,6 +218,69 @@ impl SessionRepository for SessionPgRepository {
row.get("family_id"),
row.get("oidc_id_token"),
row.get("oidc_sid"),
row.get("dpop_jkt"),
crate::domain::entities::session::SessionOrigin::from_wire(row.get("origin")),
)
})
.collect();
Ok(sessions)
}
async fn list_sessions_paginated(
&self,
user_id_filter: Option<Uuid>,
include_revoked: bool,
limit: i64,
offset: i64,
) -> SessionRepositoryResult<Vec<Session>> {
// Single SQL with nullable-user-id + include-revoked flag
// baked in as parameters, rather than four hand-forked
// queries. `$1::uuid IS NULL` short-circuits when no filter is
// set; `$2 OR (revoked = false AND expires_at > NOW())` folds
// the active-only rule into one predicate. Both branches use
// the same index (`idx_sessions_user_id`) on the filtered
// path, and a full table scan bounded by `LIMIT` on the
// unfiltered path — acceptable for an admin-triggered view
// that operators paginate through.
let rows = sqlx::query(
r#"
SELECT
id, user_id, refresh_token, expires_at,
ip_address, user_agent, created_at, revoked, family_id,
oidc_id_token, oidc_sid, dpop_jkt, origin
FROM auth.sessions
WHERE ($1::uuid IS NULL OR user_id = $1)
AND ($2 OR (revoked = false AND expires_at > NOW()))
ORDER BY created_at DESC
LIMIT $3 OFFSET $4
"#,
)
.bind(user_id_filter)
.bind(include_revoked)
.bind(limit)
.bind(offset)
.fetch_all(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
let sessions = rows
.into_iter()
.map(|row| {
Session::from_raw(
row.get("id"),
row.get("user_id"),
row.get("refresh_token"),
row.get("expires_at"),
row.get("ip_address"),
row.get("user_agent"),
row.get("created_at"),
row.get("revoked"),
row.get("family_id"),
row.get("oidc_id_token"),
row.get("oidc_sid"),
row.get("dpop_jkt"),
crate::domain::entities::session::SessionOrigin::from_wire(row.get("origin")),
)
})
.collect();
@@ -464,6 +533,66 @@ impl SessionRepository for SessionPgRepository {
Ok(result.rows_affected())
}
async fn delete_sessions_expired_before(
&self,
cutoff: chrono::DateTime<chrono::Utc>,
) -> SessionRepositoryResult<u64> {
// Same shape as `delete_expired_sessions` but the caller picks the
// cutoff instead of it being pinned to NOW(). Lets the janitor
// keep a forensic window past the natural session expiry — see
// `SessionCleanupService` and [[project_session_janitor_missing]].
let result = sqlx::query(
r#"
DELETE FROM auth.sessions
WHERE expires_at < $1
"#,
)
.bind(cutoff)
.execute(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
Ok(result.rows_affected())
}
async fn bind_dpop_jkt(&self, session_id: Uuid, dpop_jkt: &str) -> SessionRepositoryResult<()> {
// `WHERE dpop_jkt IS NULL` enforces the immutability invariant
// at the SQL level — a bound session's UPDATE affects 0 rows
// and we surface `DpopAlreadyBound`. Also guards against a
// stolen cookie replaying the bind endpoint with the
// attacker's own thumbprint on an already-bound session.
let result = sqlx::query(
r#"
UPDATE auth.sessions
SET dpop_jkt = $2
WHERE id = $1 AND dpop_jkt IS NULL
"#,
)
.bind(session_id)
.bind(dpop_jkt)
.execute(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
if result.rows_affected() == 0 {
// Distinguish "session gone" from "already bound" — the
// caller (bind endpoint) returns different HTTP shapes.
// A tiny extra SELECT here is worth the disambiguation
// because both cases are rare.
let row = sqlx::query("SELECT dpop_jkt FROM auth.sessions WHERE id = $1")
.bind(session_id)
.fetch_optional(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
return match row {
None => Err(SessionRepositoryError::NotFound(session_id.to_string())),
Some(_) => Err(SessionRepositoryError::DpopAlreadyBound),
};
}
Ok(())
}
}
// Implementation of the storage port for the application layer
@@ -496,9 +625,9 @@ impl SessionStoragePort for SessionPgRepository {
INSERT INTO auth.sessions (
id, user_id, refresh_token, expires_at,
ip_address, user_agent, created_at, revoked, family_id,
oidc_id_token, oidc_sid
oidc_id_token, oidc_sid, dpop_jkt, origin
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13
)
"#,
)
@@ -513,6 +642,8 @@ impl SessionStoragePort for SessionPgRepository {
.bind(session_clone.family_id())
.bind(session_clone.oidc_id_token())
.bind(session_clone.oidc_sid())
.bind(session_clone.dpop_jkt())
.bind(session_clone.origin().as_str())
.execute(&mut **tx)
.await
.map_err(Self::map_sqlx_error)?;
@@ -600,4 +731,34 @@ impl SessionStoragePort for SessionPgRepository {
.await
.map_err(DomainError::from)
}
async fn bind_dpop_jkt(&self, session_id: Uuid, dpop_jkt: &str) -> Result<(), DomainError> {
SessionRepository::bind_dpop_jkt(self, session_id, dpop_jkt)
.await
.map_err(DomainError::from)
}
async fn get_session_by_id(&self, session_id: Uuid) -> Result<Session, DomainError> {
SessionRepository::get_session_by_id(self, session_id)
.await
.map_err(DomainError::from)
}
async fn list_sessions_paginated(
&self,
user_id_filter: Option<Uuid>,
include_revoked: bool,
limit: i64,
offset: i64,
) -> Result<Vec<Session>, DomainError> {
SessionRepository::list_sessions_paginated(
self,
user_id_filter,
include_revoked,
limit,
offset,
)
.await
.map_err(DomainError::from)
}
}
@@ -0,0 +1,184 @@
//! DPoP-Nonce service (RFC 9449 §8) — issues and validates the
//! server-generated nonces that eliminate reliance on the client
//! clock for freshness.
//!
//! Model — a **pool of currently-valid nonces**, not a single "last"
//! value. Every nonce we hand out sits in the pool for its full
//! lifetime; multiple can be simultaneously valid (rotation overlap,
//! multi-tab). A proof's `nonce` claim is valid iff the pool still
//! remembers it.
//!
//! Rotation — every request response can carry a `DPoP-Nonce`
//! header pointing at the "current" nonce. When the current nonce
//! is older than [`ROTATION_INTERVAL`], `current_or_rotate` mints
//! a fresh one and returns it (the outgoing one keeps living in
//! the pool until its TTL expires — the overlap window). Clients
//! opportunistically pick up the fresh header and start using it;
//! in-flight requests carrying the previous nonce remain valid
//! throughout the overlap.
//!
//! Storage — in-memory `moka` LRU, no PG persistence. On server
//! restart the pool is empty → every next client request gets a
//! `use_dpop_nonce` challenge (middleware handles this) which
//! transparently rotates the client onto a fresh nonce. That's
//! why the SPA fetch interceptor (Gate 4) has a mandatory
//! challenge-retry loop.
//!
//! Scale — a hard cap on cache size bounds memory under attack.
//! At ~64 bytes/entry and a 100k cap, worst-case ~6 MB. Under
//! normal traffic the pool is far below the cap.
//!
//! **Multi-instance caveat**: each OxiCloud replica has its own
//! pool. A nonce issued by node A + validated by node B will 401 →
//! challenge → retry → one extra round trip, no security impact.
//! Elevate to a shared Redis if the operational impact ever
//! matters; for the common single-instance self-hosted deployment
//! in-memory is correct.
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64_URL_NO_PAD;
use moka::sync::Cache;
use std::sync::RwLock;
use std::time::{Duration, Instant};
/// How long a nonce is valid after issuance. Rejected outright once
/// past this window (moka TTL enforces it — no manual sweep needed).
pub const NONCE_LIFETIME: Duration = Duration::from_secs(300); // 5 min
/// Once the "current" nonce is older than this, `current_or_rotate`
/// mints a fresh one on next call. The outgoing nonce stays valid
/// in the pool until its own TTL expires, giving a 3-minute overlap
/// window during which both work. Clients pick up the fresh header
/// in the next response and switch over lazily.
pub const ROTATION_INTERVAL: Duration = Duration::from_secs(120); // 2 min
/// Max nonce entries — bounds memory under attack. LRU eviction
/// past this cap.
const MAX_POOL_SIZE: u64 = 100_000;
/// Byte length of a nonce before base64url encoding. 32 bytes →
/// 43-char b64url string, matching JWK-thumbprint dimensions so
/// operator eyes calibrate the same way for both fields.
const NONCE_BYTES: usize = 32;
/// Currently-active pool of nonces + the freshest one served in
/// `DPoP-Nonce` response headers.
pub struct DpopNonceService {
/// Pool of live nonces. Value is `()` — presence == validity;
/// TTL enforced by moka's `time_to_live`.
pool: Cache<String, ()>,
/// Freshest nonce we've issued + when — used to decide when to
/// rotate. `None` at boot, populated on first `current_or_rotate`.
current: RwLock<Option<CurrentNonce>>,
}
struct CurrentNonce {
value: String,
issued_at: Instant,
}
impl Default for DpopNonceService {
fn default() -> Self {
Self::new()
}
}
impl DpopNonceService {
pub fn new() -> Self {
Self {
pool: Cache::builder()
.max_capacity(MAX_POOL_SIZE)
.time_to_live(NONCE_LIFETIME)
.build(),
current: RwLock::new(None),
}
}
/// Return the current nonce, minting a fresh one when the last
/// mint is older than [`ROTATION_INTERVAL`] (or on cold start).
/// The returned value is what the middleware stamps into
/// outgoing `DPoP-Nonce` response headers.
pub fn current_or_rotate(&self) -> String {
// Fast path: read lock, current is still fresh → clone the string.
if let Some(cur) = self.current.read().unwrap().as_ref()
&& cur.issued_at.elapsed() < ROTATION_INTERVAL
{
return cur.value.clone();
}
// Slow path: write lock, re-check (someone else may have
// rotated between drop-read and acquire-write), otherwise
// mint fresh.
let mut guard = self.current.write().unwrap();
if let Some(cur) = guard.as_ref()
&& cur.issued_at.elapsed() < ROTATION_INTERVAL
{
return cur.value.clone();
}
let fresh = mint_nonce();
self.pool.insert(fresh.clone(), ());
*guard = Some(CurrentNonce {
value: fresh.clone(),
issued_at: Instant::now(),
});
fresh
}
/// Check whether a nonce presented by a client is still valid.
/// Returns `false` for absent-from-pool AND for
/// past-TTL-eviction; both are indistinguishable from the
/// caller's perspective.
pub fn is_valid(&self, nonce: &str) -> bool {
self.pool.contains_key(nonce)
}
}
fn mint_nonce() -> String {
// Re-use `p256`'s already-transitive `rand_core::OsRng` — no new
// dep, no version alignment risk. `OsRng` reads from the OS
// entropy source; `fill_bytes` panics on RNG failure (unreachable
// outside catastrophic OS state, and safer to crash than mint a
// guessable nonce).
use p256::elliptic_curve::rand_core::{OsRng, RngCore};
let mut buf = [0u8; NONCE_BYTES];
OsRng.fill_bytes(&mut buf);
B64_URL_NO_PAD.encode(buf)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn issues_a_nonce_of_expected_shape() {
let svc = DpopNonceService::new();
let n = svc.current_or_rotate();
// 43 chars = base64url(SHA-256-equivalent length)
assert_eq!(n.len(), 43);
assert!(
n.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'),
"nonce contains non-base64url chars: {n}"
);
}
#[test]
fn issued_nonce_validates_immediately() {
let svc = DpopNonceService::new();
let n = svc.current_or_rotate();
assert!(svc.is_valid(&n));
}
#[test]
fn returns_same_nonce_within_rotation_window() {
let svc = DpopNonceService::new();
let a = svc.current_or_rotate();
let b = svc.current_or_rotate();
assert_eq!(a, b);
}
#[test]
fn unknown_nonce_is_rejected() {
let svc = DpopNonceService::new();
assert!(!svc.is_valid("not-a-real-nonce-value-1234567890abcde"));
}
}
@@ -0,0 +1,107 @@
//! DPoP replay cache — remembers `(nonce, jti)` tuples we've
//! already verified, and rejects duplicates as replays.
//!
//! Nonce-scoped by design (see `docs/plan/dpop.md` Gate 6). A `jti`
//! is only guaranteed unique WITHIN the lifetime of a nonce; a
//! naive global-`jti` cache would falsely reject the second use of
//! a `jti` value the client happened to reuse across two nonces
//! (statistically negligible for 128-bit UUIDs but semantically
//! wrong per the spec).
//!
//! Two-scope invariant, tested below:
//! * same `jti` under DIFFERENT nonces → both accepted
//! * same `(nonce, jti)` seen twice → second is a replay
//!
//! TTL is aligned with [`super::dpop_nonce_service::NONCE_LIFETIME`]
//! (5 minutes) — once a nonce ages out of the nonce pool it cannot
//! validate anyway, so replay-cache entries against that nonce are
//! moot the moment the outer freshness check fires. Both caches
//! bounded at ~100k entries.
use moka::sync::Cache;
use std::time::Duration;
/// Same as `NONCE_LIFETIME` — see file doc.
const REPLAY_ENTRY_TTL: Duration = Duration::from_secs(300);
/// Cap. Roughly 100 bytes/entry (two short strings + moka
/// bookkeeping) → ~10 MB ceiling under sustained attack, per plan.
const MAX_ENTRIES: u64 = 100_000;
/// In-memory nonce-scoped replay tracker.
pub struct DpopReplayCache {
seen: Cache<(String, String), ()>,
}
impl Default for DpopReplayCache {
fn default() -> Self {
Self::new()
}
}
impl DpopReplayCache {
pub fn new() -> Self {
Self {
seen: Cache::builder()
.max_capacity(MAX_ENTRIES)
.time_to_live(REPLAY_ENTRY_TTL)
.build(),
}
}
/// Record a fresh `(nonce, jti)` pair, returning `true` if this
/// is the first time we've seen it (accept the proof) and
/// `false` if we've already recorded it (replay — reject).
///
/// Uses moka's atomic `entry` API so two racing verify calls
/// for the same `(nonce, jti)` — the pathological concurrent-
/// replay window — resolve to exactly one `true` and one
/// `false`, never both `true`.
pub fn check_and_record(&self, nonce: &str, jti: &str) -> bool {
let key = (nonce.to_string(), jti.to_string());
// `entry().or_insert_with(...)` is atomic across concurrent
// callers; the returned `Entry` exposes `is_fresh()` to
// distinguish "we just wrote this" from "already existed".
let entry = self.seen.entry(key).or_insert_with(|| ());
entry.is_fresh()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_seen_is_accepted() {
let c = DpopReplayCache::new();
assert!(c.check_and_record("nonce-A", "jti-1"));
}
#[test]
fn duplicate_same_scope_is_replay() {
let c = DpopReplayCache::new();
assert!(c.check_and_record("nonce-A", "jti-1"));
assert!(
!c.check_and_record("nonce-A", "jti-1"),
"second insert of same (nonce, jti) must be flagged as replay"
);
}
#[test]
fn same_jti_different_nonces_both_accepted() {
// Nonce-scoped invariant: `jti` uniqueness is only meaningful
// within a single nonce lifetime. Reusing a `jti` across
// different nonces is legitimate (the second nonce is a
// fresh replay scope) and MUST NOT trip replay detection.
let c = DpopReplayCache::new();
assert!(c.check_and_record("nonce-A", "jti-1"));
assert!(c.check_and_record("nonce-B", "jti-1"));
}
#[test]
fn different_jtis_same_nonce_both_accepted() {
let c = DpopReplayCache::new();
assert!(c.check_and_record("nonce-A", "jti-1"));
assert!(c.check_and_record("nonce-A", "jti-2"));
}
}
@@ -0,0 +1,492 @@
//! DPoP proof verifier (RFC 9449) — pure functions over a compact JWS.
//!
//! Consumes a DPoP header value produced by
//! `frontend/src/lib/auth/dpop-proof.ts` (or the `dpop-hurl-helper`
//! test binary — Gate 6b) and returns a typed verdict.
//!
//! Nothing here touches the DB or the request extractor pipeline —
//! that's the middleware's job (see `src/interfaces/middleware/dpop.rs`).
//! Keeping the verifier pure makes the failure-mode matrix trivially
//! unit-testable: `verify(proof, method, htu, expected_jkt, now)`.
//!
//! **Nonce validation is a caller responsibility for now** — the
//! nonce claim is extracted and returned as part of the OK verdict,
//! but the caller (middleware) validates it against the nonce
//! service. Wiring lands in Gate 5b; at Gate 5 the middleware
//! ignores the nonce field (opportunistic path).
//!
//! Ciphersuite: **ES256 ONLY** (ECDSA P-256 + SHA-256). Any other
//! `alg` or `crv` is a hard reject — RFC 9449 §4 mandates support
//! for ES256 and we don't accept anything looser.
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64_URL_NO_PAD;
use sha2::{Digest, Sha256};
/// Per-request context the verifier compares proof claims against.
#[derive(Debug, Clone)]
pub struct DpopRequestContext<'a> {
/// Uppercase HTTP method (e.g. `"POST"`).
pub htm: &'a str,
/// Canonical target URL: `scheme://authority/path` — NO query, NO
/// fragment. Middleware builds this from the external scheme +
/// host (`X-Forwarded-*`-aware) + `OriginalUri` path.
pub htu: &'a str,
/// Server clock (unix seconds). Injected so tests can pin it
/// deterministically.
pub now_secs: i64,
/// Session's stored thumbprint — set at login (`session.dpop_jkt`).
/// When present, the proof's JWK thumbprint MUST match.
pub expected_jkt: Option<&'a str>,
}
/// Successful verify outcome — the middleware may still need to
/// validate the nonce (Gate 5b) and jti (Gate 6, replay cache).
#[derive(Debug, Clone)]
pub struct DpopVerified {
/// RFC 7638 JWK thumbprint of the proof's public key.
/// Middleware compares to `session.dpop_jkt` (already done here
/// when `expected_jkt` was set) and may audit-log this value.
pub jkt: String,
/// Nonce claim from the proof, if any. Bootstrap-only branch has
/// `None` — the very first request per session precedes the
/// server-issued nonce, so the client can't include it.
pub nonce: Option<String>,
/// Unique proof id — the replay cache in Gate 6 keys off this.
pub jti: String,
/// Claimed issue time (unix seconds) — informational when nonce
/// is present (server clock is authoritative via nonce validity),
/// bounded ±30s when no nonce yet (bootstrap branch).
pub iat: i64,
}
/// Machine-readable failure reasons. Stringly matched by the middleware
/// for the audit `reason=` field — DO NOT rename variants without
/// coordinating with dashboards.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DpopVerifyError {
/// JWS isn't three base64url segments, or a segment fails to
/// decode, or the JSON header/claims fail to parse.
Malformed,
/// `typ` header field is not `"dpop+jwt"`.
WrongTyp,
/// `alg` header field is not `"ES256"`.
WrongAlg,
/// `jwk` header member is missing or not an EC/P-256 public key.
WrongJwk,
/// ECDSA signature does not verify over `header.payload`.
SignatureInvalid,
/// `htm` claim doesn't match the request method.
WrongHtm,
/// `htu` claim doesn't match the canonical request URL.
WrongHtu,
/// `iat` claim is missing / non-numeric.
IatMissing,
/// `iat` claim is outside the ±30s bootstrap window (no nonce yet).
IatOutOfWindow,
/// `jti` claim is missing / empty.
JtiMissing,
/// Proof's JWK thumbprint doesn't match `expected_jkt`.
JktMismatch,
}
impl DpopVerifyError {
/// Stable machine-readable reason string for audit lines.
pub fn reason(&self) -> &'static str {
match self {
Self::Malformed => "malformed_jws",
Self::WrongTyp => "wrong_typ",
Self::WrongAlg => "wrong_alg",
Self::WrongJwk => "wrong_jwk",
Self::SignatureInvalid => "signature_invalid",
Self::WrongHtm => "wrong_htm",
Self::WrongHtu => "wrong_htu",
Self::IatMissing => "iat_missing",
Self::IatOutOfWindow => "iat_out_of_window",
Self::JtiMissing => "jti_missing",
Self::JktMismatch => "jkt_mismatch",
}
}
}
pub type DpopVerifyResult = Result<DpopVerified, DpopVerifyError>;
/// ±30s tolerance on the `iat` claim when NO nonce is present
/// (bootstrap branch). Once Gate 5b lands, requests carrying a
/// server-issued nonce bypass this check — nonce validity acts as
/// the authoritative freshness signal.
const IAT_BOOTSTRAP_TOLERANCE_SECS: i64 = 30;
/// Verify a DPoP proof against a request context. See file doc for
/// scope: nonce/jti/replay checks are the caller's responsibility.
pub fn verify(proof: &str, ctx: &DpopRequestContext<'_>) -> DpopVerifyResult {
// ── 1. Split the compact JWS into three segments ─────────────
let mut parts = proof.split('.');
let (h_b64, p_b64, s_b64) = match (parts.next(), parts.next(), parts.next(), parts.next()) {
(Some(h), Some(p), Some(s), None) => (h, p, s),
_ => return Err(DpopVerifyError::Malformed),
};
let header_bytes = B64_URL_NO_PAD
.decode(h_b64)
.map_err(|_| DpopVerifyError::Malformed)?;
let payload_bytes = B64_URL_NO_PAD
.decode(p_b64)
.map_err(|_| DpopVerifyError::Malformed)?;
let signature = B64_URL_NO_PAD
.decode(s_b64)
.map_err(|_| DpopVerifyError::Malformed)?;
// ── 2. Parse header + validate typ/alg/jwk ────────────────────
let header: serde_json::Value =
serde_json::from_slice(&header_bytes).map_err(|_| DpopVerifyError::Malformed)?;
if header.get("typ").and_then(|v| v.as_str()) != Some("dpop+jwt") {
return Err(DpopVerifyError::WrongTyp);
}
if header.get("alg").and_then(|v| v.as_str()) != Some("ES256") {
return Err(DpopVerifyError::WrongAlg);
}
let jwk = header.get("jwk").ok_or(DpopVerifyError::WrongJwk)?;
if jwk.get("kty").and_then(|v| v.as_str()) != Some("EC") {
return Err(DpopVerifyError::WrongJwk);
}
if jwk.get("crv").and_then(|v| v.as_str()) != Some("P-256") {
return Err(DpopVerifyError::WrongJwk);
}
let x_b64 = jwk
.get("x")
.and_then(|v| v.as_str())
.ok_or(DpopVerifyError::WrongJwk)?;
let y_b64 = jwk
.get("y")
.and_then(|v| v.as_str())
.ok_or(DpopVerifyError::WrongJwk)?;
// ── 3. Verify the ECDSA signature ─────────────────────────────
// JWS ES256 signature is raw R||S (64 bytes for P-256), NOT DER
// — RFC 7515 A.3. `p256::ecdsa::Signature::from_slice` accepts
// exactly that layout.
let x_bytes = B64_URL_NO_PAD
.decode(x_b64)
.map_err(|_| DpopVerifyError::WrongJwk)?;
let y_bytes = B64_URL_NO_PAD
.decode(y_b64)
.map_err(|_| DpopVerifyError::WrongJwk)?;
if x_bytes.len() != 32 || y_bytes.len() != 32 {
return Err(DpopVerifyError::WrongJwk);
}
// SEC1 uncompressed point: 0x04 || X || Y.
let mut sec1 = Vec::with_capacity(65);
sec1.push(0x04);
sec1.extend_from_slice(&x_bytes);
sec1.extend_from_slice(&y_bytes);
use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier};
let vkey = VerifyingKey::from_sec1_bytes(&sec1).map_err(|_| DpopVerifyError::WrongJwk)?;
let sig = Signature::from_slice(&signature).map_err(|_| DpopVerifyError::SignatureInvalid)?;
// Signing input is EXACT bytes: base64url(header) || '.' ||
// base64url(payload). Preserve the caller's encoding — do NOT
// re-encode, since serde_json re-serialisation may reorder
// members and break signature.
let signing_input = format!("{h_b64}.{p_b64}");
vkey.verify(signing_input.as_bytes(), &sig)
.map_err(|_| DpopVerifyError::SignatureInvalid)?;
// ── 4. Parse claims + validate htm/htu/iat/jti ────────────────
let claims: serde_json::Value =
serde_json::from_slice(&payload_bytes).map_err(|_| DpopVerifyError::Malformed)?;
let htm = claims.get("htm").and_then(|v| v.as_str()).unwrap_or("");
if !htm.eq_ignore_ascii_case(ctx.htm) {
return Err(DpopVerifyError::WrongHtm);
}
let htu = claims.get("htu").and_then(|v| v.as_str()).unwrap_or("");
if htu != ctx.htu {
return Err(DpopVerifyError::WrongHtu);
}
let iat = claims
.get("iat")
.and_then(|v| v.as_i64())
.ok_or(DpopVerifyError::IatMissing)?;
let jti = claims
.get("jti")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.ok_or(DpopVerifyError::JtiMissing)?
.to_string();
let nonce = claims
.get("nonce")
.and_then(|v| v.as_str())
.map(str::to_owned);
// iat freshness check: authoritative only when NO nonce is
// present (bootstrap branch). Gate 5b will bypass this when a
// nonce is available — nonce validity is server-clock-based, so
// it moots any client-clock skew.
if nonce.is_none() && (iat - ctx.now_secs).abs() > IAT_BOOTSTRAP_TOLERANCE_SECS {
return Err(DpopVerifyError::IatOutOfWindow);
}
// ── 5. Compute JWK thumbprint (RFC 7638 §3.2 EC members) ──────
let canonical = format!(r#"{{"crv":"P-256","kty":"EC","x":"{x_b64}","y":"{y_b64}"}}"#,);
let jkt = B64_URL_NO_PAD.encode(Sha256::digest(canonical.as_bytes()));
if let Some(expected) = ctx.expected_jkt
&& expected != jkt
{
return Err(DpopVerifyError::JktMismatch);
}
Ok(DpopVerified {
jkt,
nonce,
jti,
iat,
})
}
#[cfg(test)]
mod tests {
use super::*;
use p256::ecdsa::{Signature, SigningKey, signature::Signer};
/// Deterministic-ish signing key for tests — derives 32 bytes from
/// a seed byte so each test can hold its own without pulling in
/// `rand` as a dev-dep. Any value 1..=127 works (P-256 scalar
/// must be non-zero and < curve order); we spread bytes over the
/// buffer so keys with adjacent seeds don't share high bits.
fn test_key(seed: u8) -> SigningKey {
let mut bytes = [0u8; 32];
for (i, b) in bytes.iter_mut().enumerate() {
*b = seed.wrapping_add(i as u8).wrapping_add(1);
}
SigningKey::from_bytes(&bytes.into()).expect("valid P-256 scalar")
}
/// Build a signed DPoP proof for testing — mirrors what
/// `frontend/src/lib/auth/dpop-proof.ts` produces on the client.
#[allow(clippy::too_many_arguments)]
fn make_proof(
signing_key: &SigningKey,
htm: &str,
htu: &str,
iat: i64,
jti: &str,
nonce: Option<&str>,
override_alg: Option<&str>,
override_typ: Option<&str>,
) -> (String, String) {
let vkey = signing_key.verifying_key();
let encoded = vkey.to_encoded_point(false); // uncompressed
let x = encoded.x().unwrap();
let y = encoded.y().unwrap();
let x_b64 = B64_URL_NO_PAD.encode(x);
let y_b64 = B64_URL_NO_PAD.encode(y);
let header = serde_json::json!({
"typ": override_typ.unwrap_or("dpop+jwt"),
"alg": override_alg.unwrap_or("ES256"),
"jwk": { "crv": "P-256", "kty": "EC", "x": x_b64, "y": y_b64 },
});
let mut claims = serde_json::json!({
"htm": htm,
"htu": htu,
"iat": iat,
"jti": jti,
});
if let Some(n) = nonce {
claims.as_object_mut().unwrap().insert(
"nonce".to_string(),
serde_json::Value::String(n.to_string()),
);
}
let h_b64 = B64_URL_NO_PAD.encode(header.to_string());
let p_b64 = B64_URL_NO_PAD.encode(claims.to_string());
let signing_input = format!("{h_b64}.{p_b64}");
let sig: Signature = signing_key.sign(signing_input.as_bytes());
let s_b64 = B64_URL_NO_PAD.encode(sig.to_bytes());
let proof = format!("{h_b64}.{p_b64}.{s_b64}");
// Compute canonical thumbprint the same way verify() does
let canonical = format!(r#"{{"crv":"P-256","kty":"EC","x":"{x_b64}","y":"{y_b64}"}}"#,);
let jkt = B64_URL_NO_PAD.encode(Sha256::digest(canonical.as_bytes()));
(proof, jkt)
}
fn ctx<'a>(
htm: &'a str,
htu: &'a str,
now: i64,
expected_jkt: Option<&'a str>,
) -> DpopRequestContext<'a> {
DpopRequestContext {
htm,
htu,
now_secs: now,
expected_jkt,
}
}
#[test]
fn accepts_happy_path() {
let sk = test_key(1);
let (proof, jkt) = make_proof(
&sk,
"GET",
"https://oxi.example/api/me",
1_000_000,
"jti-1",
None,
None,
None,
);
let out = verify(
&proof,
&ctx("GET", "https://oxi.example/api/me", 1_000_000, Some(&jkt)),
)
.unwrap();
assert_eq!(out.jkt, jkt);
assert_eq!(out.jti, "jti-1");
assert_eq!(out.nonce, None);
}
#[test]
fn rejects_wrong_htm() {
let sk = test_key(1);
let (proof, jkt) = make_proof(&sk, "POST", "https://x/a", 1_000_000, "j", None, None, None);
let err = verify(&proof, &ctx("GET", "https://x/a", 1_000_000, Some(&jkt))).unwrap_err();
assert_eq!(err, DpopVerifyError::WrongHtm);
assert_eq!(err.reason(), "wrong_htm");
}
#[test]
fn rejects_wrong_htu() {
let sk = test_key(1);
let (proof, jkt) = make_proof(&sk, "GET", "https://x/a", 1_000_000, "j", None, None, None);
let err = verify(&proof, &ctx("GET", "https://x/b", 1_000_000, Some(&jkt))).unwrap_err();
assert_eq!(err, DpopVerifyError::WrongHtu);
}
#[test]
fn rejects_wrong_alg() {
let sk = test_key(1);
let (proof, _jkt) = make_proof(
&sk,
"GET",
"https://x/a",
1_000_000,
"j",
None,
Some("RS256"),
None,
);
// Wrong alg reject fires BEFORE signature verify (alg is a
// header field we check first). No expected_jkt needed —
// we don't get that far.
let err = verify(&proof, &ctx("GET", "https://x/a", 1_000_000, None)).unwrap_err();
assert_eq!(err, DpopVerifyError::WrongAlg);
}
#[test]
fn rejects_wrong_typ() {
let sk = test_key(1);
let (proof, _jkt) = make_proof(
&sk,
"GET",
"https://x/a",
1_000_000,
"j",
None,
None,
Some("jwt"),
);
let err = verify(&proof, &ctx("GET", "https://x/a", 1_000_000, None)).unwrap_err();
assert_eq!(err, DpopVerifyError::WrongTyp);
}
#[test]
fn rejects_expired_iat_when_no_nonce() {
let sk = test_key(1);
let (proof, jkt) = make_proof(&sk, "GET", "https://x/a", 1_000_000, "j", None, None, None);
// now = iat + 60 → outside ±30s tolerance
let err = verify(&proof, &ctx("GET", "https://x/a", 1_000_060, Some(&jkt))).unwrap_err();
assert_eq!(err, DpopVerifyError::IatOutOfWindow);
}
#[test]
fn accepts_stale_iat_when_nonce_present() {
let sk = test_key(1);
let (proof, jkt) = make_proof(
&sk,
"GET",
"https://x/a",
1_000_000,
"j",
Some("srv-nonce"),
None,
None,
);
// now = iat + 10 minutes → would fail bootstrap check, but
// nonce is present → freshness check delegated to nonce
// validity (Gate 5b), so we accept here.
let out = verify(&proof, &ctx("GET", "https://x/a", 1_000_600, Some(&jkt))).unwrap();
assert_eq!(out.nonce.as_deref(), Some("srv-nonce"));
}
#[test]
fn rejects_jkt_mismatch() {
let sk = test_key(1);
let (proof, _jkt) = make_proof(&sk, "GET", "https://x/a", 1_000_000, "j", None, None, None);
let err = verify(
&proof,
&ctx(
"GET",
"https://x/a",
1_000_000,
Some("some-other-thumbprint-value"),
),
)
.unwrap_err();
assert_eq!(err, DpopVerifyError::JktMismatch);
}
#[test]
fn rejects_bad_signature_when_payload_tampered() {
let sk = test_key(1);
let (proof, jkt) = make_proof(&sk, "GET", "https://x/a", 1_000_000, "j", None, None, None);
// Corrupt the middle segment (payload) — signature will no
// longer verify against the tampered signing-input bytes.
let mut parts: Vec<&str> = proof.split('.').collect();
parts[1] = "bm90LWEtcmVhbC1wYXlsb2Fk"; // "not-a-real-payload"
let tampered = parts.join(".");
let err = verify(&tampered, &ctx("GET", "https://x/a", 1_000_000, Some(&jkt))).unwrap_err();
assert_eq!(err, DpopVerifyError::SignatureInvalid);
}
#[test]
fn rejects_malformed_jws() {
// Only 2 segments
let err = verify("aa.bb", &ctx("GET", "https://x/a", 0, None)).unwrap_err();
assert_eq!(err, DpopVerifyError::Malformed);
// 4 segments
let err = verify("a.b.c.d", &ctx("GET", "https://x/a", 0, None)).unwrap_err();
assert_eq!(err, DpopVerifyError::Malformed);
// Bad base64
let err = verify("!!.??.@@", &ctx("GET", "https://x/a", 0, None)).unwrap_err();
assert_eq!(err, DpopVerifyError::Malformed);
}
#[test]
fn missing_jti_is_rejected() {
// Build a proof with an empty jti — verify() rejects because
// it's essential for the replay cache to key on.
let sk = test_key(1);
let (proof, jkt) = make_proof(&sk, "GET", "https://x/a", 1_000_000, "", None, None, None);
let err = verify(&proof, &ctx("GET", "https://x/a", 1_000_000, Some(&jkt))).unwrap_err();
assert_eq!(err, DpopVerifyError::JtiMissing);
}
}
+29 -4
View File
@@ -45,6 +45,23 @@ struct JwtClaims {
pub email: Arc<str>,
/// User role for authorization checks
pub role: String,
/// RFC 9449 §5 confirmation-key claim: JWK thumbprint of the
/// browser-held DPoP keypair the session was bound to at login.
/// `None` for unbound sessions (app passwords, Nextcloud clients,
/// pre-DPoP sessions). Populated at token mint time from
/// `session.dpop_jkt`; the DPoP middleware reads it to enforce
/// "bound session → proof required" without a DB round trip.
///
/// Serialised as `{"cnf": {"jkt": "..."}}` to match RFC 9449.
#[serde(skip_serializing_if = "Option::is_none")]
pub cnf: Option<CnfClaim>,
}
/// RFC 9449 §5 confirmation-key wrapper. Only the `jkt` member is
/// used today; future extensions (`x5t#S256`, etc.) would live here.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct CnfClaim {
pub jkt: String,
}
impl From<JwtClaims> for TokenClaims {
@@ -64,6 +81,7 @@ impl From<JwtClaims> for TokenClaims {
username: claims.username,
email: claims.email,
role: claims.role,
dpop_jkt: claims.cnf.map(|c| c.jkt),
}
}
}
@@ -175,7 +193,11 @@ impl JwtTokenService {
}
impl TokenServicePort for JwtTokenService {
fn generate_access_token(&self, user: &User) -> Result<String, DomainError> {
fn generate_access_token(
&self,
user: &User,
dpop_jkt: Option<&str>,
) -> Result<String, DomainError> {
let now = Utc::now().timestamp();
// Log information for debugging
@@ -194,6 +216,9 @@ impl TokenServicePort for JwtTokenService {
username: Arc::from(user.username().unwrap_or("")),
email: Arc::from(user.email()),
role: user.role().as_str().to_string(),
cnf: dpop_jkt.map(|jkt| CnfClaim {
jkt: jkt.to_string(),
}),
};
// Log JWT claims for debugging
@@ -306,7 +331,7 @@ mod tests {
let user = create_test_user();
let token = service
.generate_access_token(&user)
.generate_access_token(&user, None)
.expect("Should generate token");
let claims = service
@@ -345,7 +370,7 @@ mod tests {
let user = create_test_user();
let token = service
.generate_access_token(&user)
.generate_access_token(&user, None)
.expect("Should generate token");
// First call: cache miss — performs full HMAC verification
@@ -372,7 +397,7 @@ mod tests {
86400,
);
let token = service
.generate_access_token(&create_test_user())
.generate_access_token(&create_test_user(), None)
.expect("Should generate token");
// Miss populates the cache; hit must hand back the very same
+4
View File
@@ -10,6 +10,9 @@ pub mod compression_service;
pub mod consistency_batch_service;
pub mod db_pool_monitor;
pub mod dedup_service;
pub mod dpop_nonce_service;
pub mod dpop_replay_cache;
pub mod dpop_verifier;
pub mod drives_consistency_service;
pub mod encrypted_blob_backend;
pub mod entry_backend;
@@ -47,6 +50,7 @@ pub mod recent_recording_hook;
pub mod retry_blob_backend;
pub mod s3_blob_backend;
pub mod search_index;
pub mod session_cleanup_service;
pub mod share_unlock_cookie;
pub mod smtp_email_sender;
pub mod swappable_blob_backend;
@@ -0,0 +1,126 @@
//! Periodic janitor that purges long-expired session rows from
//! `auth.sessions`. Naturally-expired sessions accumulate forever
//! otherwise — `SessionRepository::delete_expired_sessions()` and its
//! delayed sibling exist, but nothing was scheduling them (see
//! [[project_session_janitor_missing]] for the historical gap).
//!
//! Retention window: **3 months past `expires_at`**. Rows past the
//! natural refresh-token expiry (default 30 days) already can't
//! authenticate — expiry is checked independently at every auth path
//! (`session.is_expired()` in the refresh handler, JWT `exp` at the
//! middleware). The 3-month cushion buys ops a forensic window before
//! the row disappears entirely — a security-review after-the-fact can
//! still see "this session belonged to user X, from IP Y, minted via
//! origin Z". After that, the row is dead weight.
//!
//! Interval: 24 hours, same cadence as
//! [`super::trash_cleanup_service::TrashCleanupService`]. Session
//! cleanup is even cheaper (one SQL DELETE, no dedup GC pass), so this
//! could run more often — daily is chosen for consistency with the
//! other janitors and to keep operator noise predictable.
//!
//! Not gated behind a feature flag: expired sessions are always safe
//! to drop, and hoarding them creates a slow leak that shows up as
//! `auth.sessions` bloat months into a deployment. The one operator
//! surface is the retention window itself — hardcoded to 90 days for
//! now; if a tenant needs a different value, promote to
//! `OXICLOUD_SESSION_RETENTION_DAYS` and thread through here.
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use chrono::Utc;
use tracing::{error, info};
use crate::domain::repositories::session_repository::SessionRepository;
use crate::infrastructure::repositories::SessionPgRepository;
use crate::infrastructure::scheduler::{JobHandler, JobOutcome, JobRegistry, JobRunArgs};
/// How long a session row survives past its `expires_at` before this
/// janitor deletes it. Enough time for a security review of a
/// suspicious session to still see the row; not so long that
/// `auth.sessions` bloats indefinitely.
const RETENTION_DAYS: i64 = 90;
/// How often the sweep runs. Hours, matches the trash-cleanup cadence.
const SWEEP_INTERVAL_HOURS: u64 = 24;
pub struct SessionCleanupService {
session_repository: Arc<SessionPgRepository>,
}
impl SessionCleanupService {
pub const JOB_NAME: &'static str = "session_cleanup";
pub fn new(session_repository: Arc<SessionPgRepository>) -> Self {
Self { session_repository }
}
/// Register with the scheduler and return `Arc<Self>` for the
/// chained-constructor DI pattern (mirrors
/// `TrashCleanupService::register`).
pub async fn register(self: Arc<Self>, registry: &JobRegistry) -> Arc<Self> {
let interval = Duration::from_secs(SWEEP_INTERVAL_HOURS * 3600);
registry.register(self.clone(), Some(interval), None).await;
self
}
/// One-shot execution — deletes rows where
/// `expires_at < NOW() - RETENTION_DAYS`. Returns the row count so
/// `JobHandler::run` can shape a `JobOutcome::Ok`.
async fn run_once(&self) -> Result<u64, String> {
let cutoff = Utc::now() - chrono::Duration::days(RETENTION_DAYS);
self.session_repository
.delete_sessions_expired_before(cutoff)
.await
.map_err(|e| format!("delete_sessions_expired_before: {e}"))
}
}
#[async_trait]
impl JobHandler for SessionCleanupService {
fn name(&self) -> &str {
Self::JOB_NAME
}
/// Runs one bulk-delete of long-expired session rows. `count` on
/// the returned `JobOutcome::Ok` is the number of rows dropped
/// this tick; `extra` records the retention window operators can
/// spot-check against `OXICLOUD_ACCESS_TOKEN_EXPIRY_SECS` /
/// refresh TTL if they suspect the window is too tight.
///
/// `args.force` is ignored — there's no acceleration knob (the
/// retention window is a constant, not a runtime tunable).
async fn run(&self, _args: &JobRunArgs) -> JobOutcome {
match self.run_once().await {
Ok(0) => JobOutcome::ok_with(
0,
serde_json::json!({
"retention_days": RETENTION_DAYS,
"note": "no rows past retention window",
}),
),
Ok(deleted) => {
info!(
target: "audit",
event = "session_cleanup.purged",
rows_deleted = deleted,
retention_days = RETENTION_DAYS,
"🧹 Session janitor purged {deleted} rows past {RETENTION_DAYS}-day retention"
);
JobOutcome::ok_with(
deleted,
serde_json::json!({
"retention_days": RETENTION_DAYS,
"rows_deleted": deleted,
}),
)
}
Err(e) => {
error!("Session cleanup failed: {e}");
JobOutcome::err(format!("session cleanup failed: {e}"))
}
}
}
}
@@ -719,6 +719,14 @@ impl ThumbnailService {
/// Aspect-ratio-preserving target dimensions so the longest side equals
/// `max_dim` (clamped to ≥1 to keep the SIMD resizer happy on extreme ratios).
fn fit_dims(src_w: u32, src_h: u32, max_dim: u32) -> (u32, u32) {
// Never upsample. A sub-`max_dim` source (a favicon, a 1×1
// pixel, an avatar under the Icon size) is encoded at its
// original resolution — upscaling wastes bytes AND, on sources
// smaller than the convolution kernel's support radius,
// `fast_image_resize`'s Lanczos3/CatmullRom paths reject the
// resize outright, surfacing as `ImageError("...")` from
// `encode_thumbnail`.
let max_dim = max_dim.min(src_w.max(src_h));
if src_w > src_h {
let ratio = max_dim as f32 / src_w as f32;
(max_dim, ((src_h as f32 * ratio) as u32).max(1))
@@ -761,16 +769,23 @@ impl ThumbnailService {
filter
};
let src = ImageRef::new(src_w, src_h, src_rgb, PixelType::U8x3)
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
let mut dst = Image::new(dst_w, dst_h, PixelType::U8x3);
let opts = ResizeOptions::new().resize_alg(ResizeAlg::Convolution(filter));
Resizer::new()
.resize(&src, &mut dst, &opts)
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
// The resized RGB8 plane feeds either codec from one buffer.
let resized = dst.into_vec();
// Skip Resizer when the target already matches the source. Two
// reasons: (1) `fit_dims` caps at source size so any src<=max_dim
// path lands here; (2) fast_image_resize's convolution kernels
// reject sources smaller than their support radius, so a 1×1
// pass-through would still fail even at src==dst.
let resized = if src_w == dst_w && src_h == dst_h {
src_rgb.to_vec()
} else {
let src = ImageRef::new(src_w, src_h, src_rgb, PixelType::U8x3)
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
let mut dst = Image::new(dst_w, dst_h, PixelType::U8x3);
let opts = ResizeOptions::new().resize_alg(ResizeAlg::Convolution(filter));
Resizer::new()
.resize(&src, &mut dst, &opts)
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
dst.into_vec()
};
match format {
ThumbnailFormat::Jpeg => {
let rgb = image::RgbImage::from_raw(dst_w, dst_h, resized).ok_or_else(|| {
@@ -1676,3 +1691,33 @@ pub struct ThumbnailStats {
pub cache_size_bytes: usize,
pub max_cache_bytes: usize,
}
#[cfg(test)]
mod tests {
use super::*;
use base64::{Engine as _, engine::general_purpose::STANDARD as B64};
// A 1×1 transparent RGBA PNG — same fixture the E2E seed uploads.
// Verifies that `fit_dims` never upsamples and `encode_thumbnail`
// skips the convolution kernel for src==dst, both of which would
// otherwise surface as `ImageError` from every downstream size/
// format pair. Note: this base64 must be a valid PNG (correct chunk
// CRCs) — the `image` crate rejects malformed CRCs even where
// ImageMagick's `identify` is lenient. Regenerate with:
// python3 -c "…" | pbcopy # see tests/e2e/spa/helpers.ts for the snippet
const PIXEL_PNG_B64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII=";
#[test]
fn render_thumbnail_handles_1x1_source_for_every_size_and_format() {
let png = B64.decode(PIXEL_PNG_B64).unwrap();
for &size in ThumbnailSize::all() {
for &format in &[ThumbnailFormat::Jpeg, ThumbnailFormat::Webp] {
let out = ThumbnailService::render_thumbnail_from_data(&png, size, format)
.unwrap_or_else(|e| {
panic!("render_thumbnail_from_data({size:?}, {format:?}) failed: {e}")
});
assert!(!out.is_empty(), "empty output for {size:?} {format:?}");
}
}
}
}
+44
View File
@@ -30,6 +30,20 @@ pub const CSRF_HEADER: &str = "x-csrf-token";
/// the token row's `request_challenge` column. Limited to `/magic`
/// so it only travels back on the redemption endpoint.
pub const MAGIC_REQUEST_COOKIE: &str = "oxicloud_magic_request";
/// One-shot **non-HttpOnly** cookie carrying a fresh `DPoP-Nonce` value
/// on login-success responses (POST OPAQUE/legacy and 302 OIDC/magic-link
/// redirects alike). The SPA reads it on mount and seeds the client-side
/// nonce cache, so the very first bound request under `DPOP=required`
/// doesn't have to eat a 401 `use_dpop_nonce` challenge before its retry
/// succeeds. Short TTL: nonces rotate server-side every ~30 s, and this
/// cookie is single-shot (cleared by the SPA after reading).
pub const DPOP_NONCE_COOKIE: &str = "oxicloud_dpop_nonce";
/// TTL for the DPoP-nonce hand-off cookie. 60 s is well within the
/// server-side pool TTL, and if the SPA doesn't read it within a
/// minute the client either lacks DPoP support (harmless waste) or
/// is broken (a stale cookie doesn't hurt — the middleware challenge-
/// retry still kicks in on first use).
const DPOP_NONCE_COOKIE_MAX_AGE_SECS: i64 = 60;
/// Whether the `Secure` flag should be set on cookies.
///
@@ -231,6 +245,36 @@ pub fn append_clear_magic_request_cookie(headers: &mut HeaderMap) {
}
}
/// Stamp the DPoP-nonce hand-off cookie alongside the auth cookies on
/// any login-success response — but only when DPoP is actually enforced
/// server-side, otherwise the cookie is dead weight the client would
/// read and discard. Uses `state.dpop_nonce_service.current_or_rotate()`
/// under the hood — the SAME nonce the middleware would stamp on any
/// authenticated response, so a subsequent bound request presenting a
/// proof with `nonce = <this value>` validates against the live pool
/// without ever touching a `use_dpop_nonce` retry.
///
/// SameSite=Strict + Path=/: cookie only travels back to our origin, on
/// any route the SPA might land on after login. Survives the 302 follow
/// on OIDC / magic-link flows (Set-Cookie IS applied across redirects).
pub fn maybe_append_dpop_nonce_cookie(
headers: &mut HeaderMap,
nonce_service: &crate::infrastructure::services::dpop_nonce_service::DpopNonceService,
dpop_mode: crate::common::config::DpopMode,
) {
if matches!(dpop_mode, crate::common::config::DpopMode::Off) {
return;
}
let value = nonce_service.current_or_rotate();
let secure = if cookie_secure() { "; Secure" } else { "" };
let val = format!(
"{DPOP_NONCE_COOKIE}={value}; SameSite=Strict; Path=/; Max-Age={DPOP_NONCE_COOKIE_MAX_AGE_SECS}{secure}",
);
if let Ok(hv) = HeaderValue::from_str(&val) {
headers.append(SET_COOKIE, hv);
}
}
/// Clear the CSRF cookie (on logout).
pub fn append_clear_csrf_cookie(headers: &mut HeaderMap) {
let secure = if cookie_secure() { "; Secure" } else { "" };
+133 -3
View File
@@ -17,9 +17,10 @@ use crate::application::dtos::plugin_dto::{
};
use crate::application::dtos::settings_dto::{
AdminCreateUserDto, AdminResetPasswordDto, DashboardStatsDto, DriveKindUsageDto,
ListUsersQueryDto, MigrationStateDto, SaveOidcSettingsDto, SaveStorageSettingsDto,
SendSmtpTestDto, SmtpInfoDto, SmtpTestResultDto, StartMigrationDto, TestOidcConnectionDto,
TestStorageConnectionDto, UpdateUserActiveDto, UpdateUserQuotaDto, UpdateUserRoleDto,
ListSessionsQueryDto, ListUsersQueryDto, MigrationStateDto, SaveOidcSettingsDto,
SaveStorageSettingsDto, SendSmtpTestDto, SmtpInfoDto, SmtpTestResultDto, StartMigrationDto,
TestOidcConnectionDto, TestStorageConnectionDto, UpdateUserActiveDto, UpdateUserQuotaDto,
UpdateUserRoleDto,
};
use crate::application::dtos::user_dto::{AdminUserSummaryDto, UserDto};
use crate::application::ports::authorization_ports::AuthorizationEngine;
@@ -108,6 +109,11 @@ pub fn admin_routes() -> Router<Arc<AppState>> {
.route("/users", post(create_user))
.route("/users/{id}", get(get_user))
.route("/users/{id}", delete(delete_user))
// Session management (DPoP admin panel — see docs/plan/dpop.md
// Gate 10). List is global cross-user with `?user_id=` narrow;
// revoke sets `revoked=true` (row stays for audit).
.route("/sessions", get(list_sessions))
.route("/sessions/{id}", delete(revoke_session))
.route("/users/{id}/role", put(update_user_role))
.route("/users/{id}/active", put(update_user_active))
.route("/users/{id}/quota", put(update_user_quota))
@@ -1189,6 +1195,130 @@ pub async fn delete_user(
))
}
/// GET /api/admin/sessions?user_id=&include_revoked=&limit=&offset= — list sessions
///
/// Global cross-user listing by default. `user_id` narrows to one
/// user; omit for cross-user. `include_revoked=true` opts into
/// showing revoked / expired rows for forensics (default hides).
/// Response is `{sessions, limit, offset}` — no total count (would
/// require a second scan; the panel paginates on presence of
/// exactly `limit` rows returned).
#[utoipa::path(
get,
path = "/api/admin/sessions",
params(
("user_id" = Option<String>, Query, description = "Narrow to one user (UUID); omit for cross-user"),
("include_revoked" = Option<bool>, Query, description = "Include revoked + expired rows (default false — active only)"),
("limit" = Option<i64>, Query, description = "Max rows to return (default 100, max 500)"),
("offset" = Option<i64>, Query, description = "Pagination offset")
),
responses(
(status = 200, description = "List of sessions"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Admin required")
),
security(("bearerAuth" = [])),
tag = "admin"
)]
pub async fn list_sessions(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Query(query): Query<ListSessionsQueryDto>,
) -> Result<impl IntoResponse, AppError> {
let auth = state
.auth_service
.as_ref()
.ok_or_else(|| AppError::internal_error("Auth service not configured"))?;
let limit = query.limit.unwrap_or(100).min(500);
let offset = query.offset.unwrap_or(0);
let include_revoked = query.include_revoked.unwrap_or(false);
let user_id_filter = match query.user_id.as_deref() {
Some(s) => Some(Uuid::parse_str(s).map_err(|_| AppError::bad_request("Invalid user_id"))?),
None => None,
};
// Pass the caller's DPoP thumbprint through the SessionCaller
// wrapper so the DTO can flag which row is the admin's own
// current session (`is_current = true`). Rendered as a "this
// is you" badge — prevents accidentally revoking the session
// the click came from. `None` when the admin is unbound (rare
// — legacy / migration-window sessions), in which case no row
// highlights.
let caller = crate::application::dtos::session_dto::SessionCaller {
id: auth_user.id,
dpop_jkt: auth_user.dpop_jkt.as_deref(),
};
let sessions = auth
.auth_application_service
.admin_list_sessions_with_perms(
state.authorization.as_ref(),
caller,
user_id_filter,
include_revoked,
limit,
offset,
)
.await
.map_err(AppError::from)?;
// Also publish the current access-token TTL so the admin panel can
// render an honest "revoke takes effect within N seconds" warning
// above the table. Revoking a session flips the DB row (breaks the
// refresh path), but any in-flight JWT stays valid until its `exp`
// — which is `access_token_expiry_secs` from now. Showing this
// number keeps the UX honest instead of implying instant kill.
let access_token_expiry_secs = state.core.config.auth.access_token_expiry_secs;
Ok(Json(serde_json::json!({
"sessions": sessions,
"limit": limit,
"offset": offset,
"access_token_expiry_secs": access_token_expiry_secs,
})))
}
/// DELETE /api/admin/sessions/:id — revoke a session
#[utoipa::path(
delete,
path = "/api/admin/sessions/{id}",
params(("id" = String, Path, description = "Session UUID")),
responses(
(status = 200, description = "Session revoked"),
(status = 400, description = "Invalid UUID"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Admin required"),
(status = 404, description = "Session not found")
),
security(("bearerAuth" = [])),
tag = "admin"
)]
pub async fn revoke_session(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
let session_id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?;
let auth = state
.auth_service
.as_ref()
.ok_or_else(|| AppError::internal_error("Auth service not configured"))?;
let caller = crate::application::dtos::session_dto::SessionCaller {
id: auth_user.id,
dpop_jkt: auth_user.dpop_jkt.as_deref(),
};
auth.auth_application_service
.admin_revoke_session_with_perms(state.authorization.as_ref(), caller, session_id)
.await
.map_err(AppError::from)?;
Ok((
StatusCode::OK,
Json(serde_json::json!({ "message": "Session revoked" })),
))
}
/// PUT /api/admin/users/:id/role — change user role
#[utoipa::path(
put,
+144 -5
View File
@@ -19,7 +19,7 @@ use crate::application::services::auth_application_service::{OidcCallbackResult,
use crate::common::di::AppState;
use crate::interfaces::api::cookie_auth;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::CurrentUserId;
use crate::interfaces::middleware::auth::{AuthUser, CurrentUserId};
use crate::interfaces::middleware::trusted_proxy::client_ip_from_parts;
use serde::Deserialize;
@@ -55,6 +55,7 @@ pub fn auth_protected_routes() -> Router<Arc<AppState>> {
// docs/plan/oidc-account-linking.md.
.route("/oidc/link/start", post(oidc_link_start))
.route("/oidc/unlink", post(oidc_unlink))
.route("/dpop/bind", post(dpop_bind))
}
/// Rate-limited auth routes, split out so main.rs can apply per-endpoint
@@ -392,10 +393,19 @@ pub async fn login(
));
}
// Extract the User-Agent once — the audit lines already carry
// `client_ip` on the request-scope span; passing both to the
// service lets `create_session` capture them on the row so the
// admin panel can show *who logged in from where*.
let user_agent = headers
.get(axum::http::header::USER_AGENT)
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
// Try the normal login process
match auth_service
.auth_application_service
.login(dto.clone())
.login(dto.clone(), Some(client_ip.clone()), user_agent.clone())
.await
{
Ok(auth_response) => {
@@ -429,6 +439,14 @@ pub async fn login(
state.core.config.auth.refresh_token_expiry_secs,
);
cookie_auth::append_csrf_cookie(response.headers_mut(), auth_response.expires_in);
// Seed the SPA's DPoP-nonce cache so the first bound request
// after login doesn't eat a `use_dpop_nonce` challenge → retry.
// No-op when `dpop_mode = off`.
cookie_auth::maybe_append_dpop_nonce_cookie(
response.headers_mut(),
&state.dpop_nonce_service,
state.core.config.auth.dpop_mode,
);
// Diagnostic: warn when Secure cookies are set but the request
// arrived over plain HTTP, the browser will reject them (#241).
@@ -546,6 +564,7 @@ pub async fn login(
)]
pub async fn refresh_token(
State(state): State<Arc<AppState>>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Result<Response, AppError> {
@@ -567,9 +586,19 @@ pub async fn refresh_token(
refresh_token: refresh_tok,
};
// Refresh rotates the session row — capture current IP + UA so the
// NEW row's `ip_address`/`user_agent` reflect the latest observed
// client (see `sessions.rotate_session`). Old row keeps its own
// capture from creation time.
let client_ip = client_ip_from_parts(&headers, Some(peer), false);
let user_agent = headers
.get(axum::http::header::USER_AGENT)
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
let auth_response = auth_service
.auth_application_service
.refresh_token(dto)
.refresh_token(dto, Some(client_ip), user_agent)
.await?;
tracing::info!("Token refresh successful, new token issued");
@@ -583,6 +612,12 @@ pub async fn refresh_token(
state.core.config.auth.refresh_token_expiry_secs,
);
cookie_auth::append_csrf_cookie(response.headers_mut(), auth_response.expires_in);
// Seed the SPA's DPoP-nonce cache — see the login handler above.
cookie_auth::maybe_append_dpop_nonce_cookie(
response.headers_mut(),
&state.dpop_nonce_service,
state.core.config.auth.dpop_mode,
);
Ok(response)
}
@@ -599,8 +634,9 @@ pub async fn refresh_token(
)]
pub async fn get_current_user(
State(state): State<Arc<AppState>>,
CurrentUserId(user_id): CurrentUserId,
auth_user: AuthUser,
) -> Result<impl IntoResponse, AppError> {
let user_id = auth_user.id;
let auth_service = state
.auth_service
.as_ref()
@@ -636,6 +672,16 @@ pub async fn get_current_user(
user.force_password_change = flags.force_password_change;
}
// Session-binding state — read from the JWT `cnf.jkt` claim
// (surfaced by the auth middleware into `CurrentUser.dpop_jkt`).
// Present ⇒ the session that minted this JWT was bound; absent ⇒
// the session is unbound and the SPA should call `/dpop/bind`
// to attach the browser's keypair (OIDC / magic-link redirect
// flow). Skips an otherwise-redundant `POST /dpop/bind` on every
// page load which would return 409 `already_bound` and litter
// the audit stream.
user.is_dpop_bound = auth_user.dpop_jkt.is_some();
Ok((StatusCode::OK, Json(user)))
}
@@ -1007,6 +1053,75 @@ pub async fn logout(
Ok(response)
}
/// Post-redirect DPoP bind DTO — only field is the JWK thumbprint.
#[derive(Debug, serde::Deserialize, ToSchema)]
pub struct DpopBindDto {
/// Base64url SHA-256 of the canonical public-key JWK (RFC 7638) —
/// exactly 43 characters, `[A-Za-z0-9_-]`.
#[serde(rename = "dpop_jkt", alias = "dpopJkt")]
pub dpop_jkt: String,
}
/// One-shot bind a DPoP JWK thumbprint to the caller's current session.
///
/// Purpose: post-redirect flows (OIDC callback, magic-link redemption)
/// create the session before the SPA has a chance to send its DPoP
/// keypair thumbprint. The SPA calls this endpoint immediately after
/// the redirect lands, so the session graduates from unbound to bound
/// before the first authenticated `/api/*` request.
///
/// Contract:
/// * 200 on success — session now carries the thumbprint.
/// * 400 if the thumbprint is malformed (wrong length / non-base64url).
/// * 409 if the session already carries a thumbprint (anti-downgrade
/// invariant per `docs/plan/dpop.md` — a bound session cannot be
/// re-bound to a different key).
/// * 401 if no session (auth middleware layer emits this).
#[utoipa::path(
post,
path = "/api/auth/dpop/bind",
request_body = DpopBindDto,
responses(
(status = 200, description = "Thumbprint bound"),
(status = 400, description = "Malformed thumbprint"),
(status = 401, description = "Not authenticated"),
(status = 409, description = "Session already bound"),
),
security(("bearerAuth" = [])),
tag = "auth"
)]
pub async fn dpop_bind(
State(state): State<Arc<AppState>>,
CurrentUserId(_user_id): CurrentUserId,
headers: HeaderMap,
Json(dto): Json<DpopBindDto>,
) -> Result<StatusCode, AppError> {
let auth = state
.auth_service
.as_ref()
.ok_or_else(|| AppError::internal_error("Authentication service not configured"))?;
// The auth middleware validates the access token but doesn't
// expose the session id. Look it up via the refresh cookie —
// same shape logout uses. Refresh cookie is HttpOnly + SameSite,
// so an attacker who has the access token but not the refresh
// cookie (theft window: seconds between token mint and refresh
// cookie install) simply gets 400.
let refresh_token = cookie_auth::extract_cookie_value(&headers, cookie_auth::REFRESH_COOKIE)
.ok_or_else(|| AppError::unauthorized("Refresh cookie required to identify session"))?;
let session_id = auth
.auth_application_service
.get_session_id_by_refresh_token(&refresh_token)
.await?
.ok_or_else(|| AppError::unauthorized("Session not found"))?;
auth.auth_application_service
.bind_dpop_jkt_to_session(session_id, &dto.dpop_jkt)
.await?;
Ok(StatusCode::OK)
}
/// OIDC Back-Channel Logout 1.0 receiver.
///
/// The IdP POSTs a signed `logout_token` JWT here when a user's SSO
@@ -1490,6 +1605,8 @@ pub async fn oidc_unlink(
)]
pub async fn oidc_callback(
State(state): State<Arc<AppState>>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Query(query): Query<OidcCallbackQueryDto>,
) -> Result<impl IntoResponse, AppError> {
let auth_service = state
@@ -1509,6 +1626,16 @@ pub async fn oidc_callback(
tracing::info!("OIDC callback received with code");
// Capture IP + UA so the OIDC-minted session row lands populated
// (admin panel would otherwise show "—" for SSO logins). Callback
// is a browser-initiated GET after the IdP redirect, so peer is
// the browser and User-Agent is the browser's.
let client_ip = client_ip_from_parts(&headers, Some(peer), false);
let user_agent = headers
.get(axum::http::header::USER_AGENT)
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
// Exchange code, validate state/nonce/PKCE, authenticate user.
// Any Err path (expired state on refresh, consumed code on replay,
// anti-takeover email refusal, etc.) is caught below and turned
@@ -1517,7 +1644,13 @@ pub async fn oidc_callback(
// mid-navigation from the IdP, not the SPA. The SPA login page
// renders localized copy per key.
let result = match auth_app
.oidc_callback(&query.code, &query.state, &state.locale_registry)
.oidc_callback(
&query.code,
&query.state,
&state.locale_registry,
Some(client_ip),
user_agent,
)
.await
{
Ok(r) => r,
@@ -1678,6 +1811,12 @@ pub async fn oidc_exchange(
state.core.config.auth.refresh_token_expiry_secs,
);
cookie_auth::append_csrf_cookie(response.headers_mut(), auth_response.expires_in);
// Seed the SPA's DPoP-nonce cache — see the login handler above.
cookie_auth::maybe_append_dpop_nonce_cookie(
response.headers_mut(),
&state.dpop_nonce_service,
state.core.config.auth.dpop_mode,
);
Ok(response)
}
@@ -148,6 +148,7 @@ struct RedeemQuery {
)]
async fn redeem_magic_link(
State(state): State<Arc<AppState>>,
axum::extract::ConnectInfo(peer): axum::extract::ConnectInfo<std::net::SocketAddr>,
Path(token): Path<String>,
Query(query): Query<RedeemQuery>,
RequestLocale(locale): RequestLocale,
@@ -171,12 +172,26 @@ async fn redeem_magic_link(
.map(|v| v == "1" || v == "true")
.unwrap_or(false);
// Capture IP + UA for the newly minted session row (admin sessions
// panel renders these; NULLs would show as "—").
let client_ip = crate::interfaces::middleware::trusted_proxy::client_ip_from_parts(
&headers,
Some(peer),
false,
);
let user_agent = headers
.get(axum::http::header::USER_AGENT)
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
match auth_svc
.auth_application_service
.redeem_magic_link(
&token,
incoming_challenge.as_deref(),
cross_browser_confirmed,
Some(client_ip),
user_agent,
)
.await
{
@@ -567,6 +582,17 @@ fn build_success_response(state: &Arc<AppState>, redemption: MagicLinkRedemption
state.core.config.auth.refresh_token_expiry_secs,
);
cookie_auth::append_csrf_cookie(response.headers_mut(), redemption.auth.expires_in);
// Seed the SPA's DPoP-nonce cache so the first bound request after
// the redirect (typically the `bindDpopIfPossible` POST or the layout's
// `session.load()` probe) has a valid nonce and doesn't eat a
// `use_dpop_nonce` challenge → retry cycle. Cookie survives the 302
// follow (Set-Cookie is applied by the browser across redirects,
// unlike other response headers). No-op when `dpop_mode = off`.
cookie_auth::maybe_append_dpop_nonce_cookie(
response.headers_mut(),
&state.dpop_nonce_service,
state.core.config.auth.dpop_mode,
);
// Clear the request-challenge cookie — it's single-use and we don't
// want a stale value on the browser confusing a later flow.
cookie_auth::append_clear_magic_request_cookie(response.headers_mut());
@@ -537,6 +537,11 @@ pub struct OpaqueLoginKe3Dto {
pub exchange_id: ExchangeId,
#[serde(rename = "finishLoginRequest")]
pub finish_login_request: String,
/// DPoP JWK thumbprint the client generated at page load. When
/// present, binds the new session to a browser-held keypair (RFC
/// 9449). Absent → session created unbound. See `docs/plan/dpop.md`.
#[serde(default, rename = "dpopJkt", alias = "dpop_jkt")]
pub dpop_jkt: Option<String>,
}
/// KE1: user lookup → envelope fetch → `ServerLogin::start` → stash
@@ -677,6 +682,8 @@ pub async fn login_ke1(
)]
pub async fn login_ke3(
State(state): State<Arc<AppState>>,
axum::extract::ConnectInfo(peer): axum::extract::ConnectInfo<std::net::SocketAddr>,
headers: axum::http::HeaderMap,
Json(dto): Json<OpaqueLoginKe3Dto>,
) -> Result<impl IntoResponse, AppError> {
let _svc = require_opaque_service(&state)?;
@@ -759,12 +766,32 @@ pub async fn login_ke3(
invalid_credentials()
})?;
// Capture client IP + User-Agent so `sessions.ip_address` /
// `user_agent` land populated instead of NULL (admin panel would
// otherwise render "—"). Both are per-session and only refresh
// on rotation, matching the login pattern.
let client_ip = crate::interfaces::middleware::trusted_proxy::client_ip_from_parts(
&headers,
Some(peer),
false,
);
let user_agent = headers
.get(axum::http::header::USER_AGENT)
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
// Mint the session BEFORE stamping opaque_migrated_at — if the
// session mint fails (rare, but not impossible under DB failure),
// we don't want to have flipped the migration flag for a user
// whose login didn't actually complete.
let session = auth
.mint_session_for_authenticated_user(user)
.mint_session_for_authenticated_user(
user,
dto.dpop_jkt,
Some(client_ip),
user_agent,
crate::domain::entities::session::SessionOrigin::Opaque,
)
.await
.map_err(AppError::from)?;
@@ -808,6 +835,14 @@ pub async fn login_ke3(
state.core.config.auth.refresh_token_expiry_secs,
);
cookie_auth::append_csrf_cookie(response.headers_mut(), session.expires_in);
// Seed the SPA's DPoP-nonce cache so the first bound request after
// login doesn't eat a `use_dpop_nonce` challenge → retry cycle.
// No-op when `dpop_mode = off`.
cookie_auth::maybe_append_dpop_nonce_cookie(
response.headers_mut(),
&state.dpop_nonce_service,
state.core.config.auth.dpop_mode,
);
Ok(response)
}
+36
View File
@@ -240,6 +240,23 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
handlers::admin_handler::cancel_job,
handlers::admin_handler::list_job_runs,
handlers::admin_handler::get_job_run,
// Admin sessions panel — list + revoke. Function names lack
// the `_admin_` suffix; the `/api/admin/` prefix comes from
// the router mount, not the handler name.
handlers::admin_handler::list_sessions,
handlers::admin_handler::revoke_session,
// OPAQUE aPAKE endpoints — full register + login handshake
// (`docs/plan/opaque-only.md`). Public routes; the register/*
// pair is session-authenticated (the SPA already has a
// legacy-login bearer before it enrolls an envelope, per the
// Phase 2 silent-migration flow), the login/* triplet is not
// (they ARE the login).
handlers::opaque_auth_handler::opaque_params,
handlers::opaque_auth_handler::register_start,
handlers::opaque_auth_handler::register_finish,
handlers::opaque_auth_handler::login_lookup,
handlers::opaque_auth_handler::login_ke1,
handlers::opaque_auth_handler::login_ke3,
// Grant / ReBAC handlers (free functions)
handlers::grant_handler::create_grant,
handlers::grant_handler::revoke_grant,
@@ -306,6 +323,25 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
SystemStatus,
OidcProviderInfoDto,
OidcExchangeDto,
// Admin sessions panel — wire shape for `/api/admin/sessions`.
// `SessionOrigin` is the discriminated enum for the `origin`
// field, so it must ship separately for consumers to type
// the union.
crate::application::dtos::session_dto::SessionSummaryDto,
crate::domain::entities::session::SessionOrigin,
// OPAQUE aPAKE — request/response shapes for every step in
// the register + login handshake. Base64url-wrapped OPRF /
// AKE payloads; see docs/plan/opaque-only.md for the wire
// grammar. `OpaqueParamsResponse` is the /params publish.
handlers::opaque_auth_handler::OpaqueRegisterStartDto,
handlers::opaque_auth_handler::OpaqueRegisterStartResponse,
handlers::opaque_auth_handler::OpaqueRegisterFinishDto,
handlers::opaque_auth_handler::OpaqueLookupDto,
handlers::opaque_auth_handler::OpaqueLookupResponse,
handlers::opaque_auth_handler::OpaqueLoginKe1Dto,
handlers::opaque_auth_handler::OpaqueLoginKe1Response,
handlers::opaque_auth_handler::OpaqueLoginKe3Dto,
handlers::opaque_auth_handler::OpaqueParamsResponse,
// Share schemas
ShareDto,
CreateShareDto,
+97
View File
@@ -0,0 +1,97 @@
//! Prometheus `/metrics` exporter — opt-in, isolated listener.
//!
//! Enabled iff `OXICLOUD_METRICS_LISTEN` is set (see
//! [`crate::common::config::AppConfig::metrics_listen`]). When unset,
//! no recorder is installed and every `metrics::counter!(…)` call
//! across the codebase compiles to a no-op — no runtime cost, no
//! endpoint bound. When set, this module:
//!
//! 1. Installs the process-global Prometheus recorder (once — panics
//! if called twice, so [`spawn`] MUST be a single-call site).
//! 2. Binds a fresh `axum` `Router` on the configured address exposing
//! only `GET /metrics`. Deliberately **not merged** into the main
//! API router — operators bind to loopback / a private interface
//! (typical: `127.0.0.1:9090` for a node_exporter-adjacent scrape)
//! without any auth, CSRF, or DPoP layer in front. Public exposure
//! is an operator choice via the bind address, not an app default.
//! 3. Spawns the listener on a detached tokio task — the metrics
//! endpoint's lifetime tracks the runtime, and a listener error
//! logs but doesn't take the main server down.
//!
//! Counter naming follows Prometheus conventions:
//! `oxicloud_<subsystem>_<verb>_total{label=…}`. Emission is
//! **duplicated** with existing audit `tracing::info!(target: "audit", …)`
//! lines — logs stay authoritative for incident forensics; counters
//! are for rate / rollup dashboards. Never remove one when adding the
//! other.
//!
//! Starter counter surface (extend as needed):
//! * `oxicloud_dpop_verify_failed_total{reason}`
//! * `oxicloud_dpop_proof_missing_total`
//! * `oxicloud_dpop_header_missing_on_bound_session_total`
//! * `oxicloud_dpop_replay_detected_total`
//! * `oxicloud_dpop_nonce_challenges_issued_total`
use axum::{Router, extract::State, http::header, response::IntoResponse, routing::get};
use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
use std::net::SocketAddr;
/// Error type returned by [`spawn`]. Uses the same `Box<dyn Error>`
/// shape `main` already threads for setup failures — one less crate
/// dep (`anyhow`) and no coupling to a specific error framework.
pub type BoxError = Box<dyn std::error::Error + Send + Sync>;
/// Install the Prometheus recorder and spawn the `/metrics` listener.
///
/// Idempotent-unsafe: MUST be called at most once per process (the
/// recorder is a process-global singleton). Caller (main.rs) checks
/// `config.metrics_listen.is_some()` — no runtime guard here.
///
/// Returns immediately after `bind` succeeds; the listener runs on a
/// detached tokio task. A bind failure returns the error so main can
/// decide whether to abort (recommended) or continue without metrics.
pub async fn spawn(bind: SocketAddr) -> Result<(), BoxError> {
let handle: PrometheusHandle =
PrometheusBuilder::new()
.install_recorder()
.map_err(|err| -> BoxError {
format!("failed to install Prometheus recorder: {err}").into()
})?;
let app = Router::new()
.route("/metrics", get(scrape))
.with_state(handle);
let listener = tokio::net::TcpListener::bind(bind)
.await
.map_err(|err| -> BoxError {
format!("failed to bind metrics listener on {bind}: {err}").into()
})?;
let actual = listener.local_addr()?;
tracing::info!(
target: "oxicloud::metrics",
"📊 Prometheus /metrics listening on http://{actual}/metrics",
);
tokio::spawn(async move {
if let Err(err) = axum::serve(listener, app).await {
tracing::error!(
target: "oxicloud::metrics",
"metrics listener terminated with error: {err}",
);
}
});
Ok(())
}
/// Render the current Prometheus text-format snapshot. Content-type
/// per spec: `text/plain; version=0.0.4`; scrapers parse strictly.
async fn scrape(State(handle): State<PrometheusHandle>) -> impl IntoResponse {
(
[(
header::CONTENT_TYPE,
"text/plain; version=0.0.4; charset=utf-8",
)],
handle.render(),
)
}
+7
View File
@@ -224,6 +224,7 @@ pub async fn auth_middleware(
username: Arc::clone(&claims.username),
email: Arc::clone(&claims.email),
role,
dpop_jkt: claims.dpop_jkt.clone(),
});
request.extensions_mut().insert(current_user);
tracing::Span::current()
@@ -267,11 +268,16 @@ pub async fn auth_middleware(
"App password authentication successful for user: {}",
uname
);
// App-password sessions are always unbound —
// they belong to NC clients / CLI / mobile
// tools without WebCrypto. DPoP middleware
// exempts them.
let current_user = Arc::new(CurrentUser {
id: user_id,
username: uname,
email,
role,
dpop_jkt: None,
});
request.extensions_mut().insert(current_user);
tracing::Span::current()
@@ -341,6 +347,7 @@ pub async fn auth_middleware(
username: Arc::clone(&claims.username),
email: Arc::clone(&claims.email),
role,
dpop_jkt: claims.dpop_jkt.clone(),
});
request.extensions_mut().insert(current_user);
request.extensions_mut().insert(CookieAuthenticated);
+415
View File
@@ -0,0 +1,415 @@
//! DPoP proof enforcement middleware (RFC 9449).
//!
//! Runs AFTER the auth middleware — reads the `CurrentUser`
//! extension to know the caller has an authenticated session, and
//! validates the `DPoP` header against the session's stored JWK
//! thumbprint (`session.dpop_jkt`).
//!
//! Mode dispatch (from `OXICLOUD_DPOP_MODE`):
//! * **Off** — pass-through, no work. Safe default.
//! * **Opportunistic** — verify when present, allow when absent.
//! Rollout mode: catches client bugs before enforcement.
//! * **Required** — bound sessions MUST present a valid proof.
//! Unbound sessions (`dpop_jkt IS NULL`) remain exempt (app
//! passwords, legacy).
//!
//! Failure response shape mirrors RFC 9449 §7.1:
//! * generic bad proof → `401` + `WWW-Authenticate: DPoP
//! error="invalid_dpop_proof"`
//! * nonce missing / stale (Gate 5b) → `401` +
//! `WWW-Authenticate: DPoP error="use_dpop_nonce"` +
//! `DPoP-Nonce: <fresh>` — client retries once, transparently.
//!
//! Body is JSON `{"error_type": "DpopVerificationFailed"}` in both
//! cases (anti-enumeration: same shape regardless of reason; the
//! audit line carries the machine-readable reason).
//!
//! Every response — success OR failure — also gets a `DPoP-Nonce`
//! header pointing at the currently-fresh server-issued nonce.
//! Clients cache it; the next request presents it and skips the
//! challenge round trip.
//!
//! Replay detection: after nonce validation succeeds, the
//! `(nonce, jti)` pair is recorded in a moka LRU. A second proof
//! carrying the same `(nonce, jti)` — the classic replay window —
//! fires `dpop.replay_detected` and returns 401 with the standard
//! `invalid_dpop_proof` error shape.
use axum::extract::{OriginalUri, Request, State};
use axum::http::{HeaderMap, HeaderValue, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use std::sync::Arc;
use crate::common::config::DpopMode;
use crate::common::di::AppState;
use crate::infrastructure::services::dpop_verifier::{
DpopRequestContext, DpopVerifyError, verify as verify_proof,
};
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::CurrentUser;
/// Resolve the request's external `(scheme, host)` — what the client
/// sees the URL as, which is what its DPoP proof's `htu` was built
/// from. Behind a reverse proxy, the internal request scheme +
/// authority differ from the external ones; without normalising here
/// the verifier fires `wrong_htu` on every request.
///
/// Priority chain (RFC 7239-adjacent — mirror what oxicloud audit
/// spans use for `client_ip`):
/// 1. `X-Forwarded-Proto` + `X-Forwarded-Host`
/// 2. `Host` header with scheme inferred from `is_https` request
/// 3. Fallback (`http` + `localhost`) — dev-only, unrepresentative
///
/// NB: no trust-boundary check here. If your deployment lets
/// arbitrary clients set `X-Forwarded-*`, they can already forge
/// audit-log IPs everywhere else — that's an operator responsibility
/// solved by the trusted-proxy config, not this helper.
fn external_scheme_host(headers: &HeaderMap) -> (String, String) {
let scheme = headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
.map(|s| s.split(',').next().unwrap_or(s).trim().to_owned())
.unwrap_or_else(|| "http".to_owned());
let host = headers
.get("x-forwarded-host")
.or_else(|| headers.get("host"))
.and_then(|v| v.to_str().ok())
.map(|s| s.split(',').next().unwrap_or(s).trim().to_owned())
.unwrap_or_else(|| "localhost".to_owned());
(scheme, host)
}
/// Middleware entry point mounted on authenticated `/api/*` subtrees.
pub async fn require_dpop_layer(
State(state): State<Arc<AppState>>,
request: Request,
next: Next,
) -> Response {
let mode = state.core.config.auth.dpop_mode;
if mode == DpopMode::Off {
return next.run(request).await;
}
let nonce_service = state.dpop_nonce_service.clone();
let replay_cache = state.dpop_replay_cache.clone();
// No authenticated user → pass through (upstream auth layer
// already handled or will handle the 401). We only concern
// ourselves with proof-carrying requests on authenticated paths.
let Some(current_user) = request.extensions().get::<Arc<CurrentUser>>().cloned() else {
return next.run(request).await;
};
let dpop_header = request
.headers()
.get("DPoP")
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
// Gate 9 enforcement — the session's binding (from the JWT's
// `cnf.jkt` claim, populated at token mint time from
// `session.dpop_jkt`) tells us whether a proof is REQUIRED:
//
// * unbound session (`dpop_jkt IS NONE`) — proof optional.
// Covers app passwords, NC clients, pre-DPoP sessions.
// * bound session — proof MANDATORY in required mode; a
// warning-only signal in opportunistic mode so operators
// can spot stale SPA versions before flipping enforcement.
let expected_jkt = current_user.dpop_jkt.as_deref();
// Diagnostic fields shared by both branches — `referer` is
// usually the smoking gun for "which SPA page sent this?";
// `user_agent` helps distinguish SPA (`Mozilla/…`), Node-side
// Playwright helper (`node`), and legacy client (blank).
let req_method = request.method().to_string();
let req_path = request.uri().path().to_owned();
let req_referer = request
.headers()
.get("referer")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_owned();
let req_user_agent = request
.headers()
.get("user-agent")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_owned();
let Some(proof) = dpop_header else {
match (mode, expected_jkt) {
(DpopMode::Required, Some(_)) => {
// Distinct event name: `dpop.proof_missing` means
// no header was on the wire at all — no verification
// happened. `dpop.verify_failed` is reserved for
// proofs that WERE present but failed cryptographic
// or claim checks. Log aggregators key off `event`
// separately from `reason`, so the split matters.
tracing::info!(
target: "audit",
event = "dpop.proof_missing",
reason = "proof_missing_on_bound_session",
caller_id = %current_user.id,
method = %req_method,
path = %req_path,
referer = %req_referer,
user_agent = %req_user_agent,
"👮🏻‍♂️ DPoP required: bound session request has no proof",
);
metrics::counter!("oxicloud_dpop_proof_missing_total").increment(1);
return nonce_challenge_response(&nonce_service);
}
(DpopMode::Opportunistic, Some(_)) => {
// Warning-only — telemetry for the rollout window.
// Emit the signal so operators can decide when to
// flip default to `required`; the request still
// completes so old clients don't break.
tracing::info!(
target: "audit",
event = "dpop.header_missing_but_session_bound",
caller_id = %current_user.id,
method = %req_method,
path = %req_path,
referer = %req_referer,
user_agent = %req_user_agent,
"⚠️ DPoP: bound session sent request without a proof",
);
metrics::counter!("oxicloud_dpop_header_missing_on_bound_session_total")
.increment(1);
}
_ => { /* unbound session or off mode — nothing to do */ }
}
let response = next.run(request).await;
return stamp_current_nonce(response, &nonce_service);
};
// Build canonical htu — external scheme + host (`X-Forwarded-*`
// aware) + OriginalUri path (nest-strip-safe). Query stripped
// per RFC 9449 §4.2.
let (scheme, host) = external_scheme_host(request.headers());
let path = request
.extensions()
.get::<OriginalUri>()
.map(|u| u.0.path().to_owned())
.unwrap_or_else(|| request.uri().path().to_owned());
let htu = format!("{scheme}://{host}{path}");
let method = request.method().as_str().to_owned();
let now_secs = chrono::Utc::now().timestamp();
let ctx = DpopRequestContext {
htm: &method,
htu: &htu,
now_secs,
// Gate 9: pin to the session's binding when present. The
// verifier returns `JktMismatch` if the proof's public
// key thumbprint doesn't match — an attacker who stole a
// bound cookie AND generated their own DPoP keypair fails
// here. `None` means the session was minted unbound so
// any well-formed proof passes the jkt check (still gets
// htm/htu/nonce/replay verification).
expected_jkt,
};
match verify_proof(&proof, &ctx) {
Ok(verified) => {
// Nonce validation: the verifier extracted the claim; if
// present, it MUST be in our live pool. Absent → OK on
// the bootstrap request, but the challenge below MUST
// still fire so the very next request carries a nonce.
let live_nonce = match verified.nonce.as_deref() {
Some(n) if !nonce_service.is_valid(n) => {
tracing::info!(
target: "audit",
event = "dpop.verify_failed",
reason = "nonce_stale",
method = %method,
htu = %htu,
"👮🏻‍♂️ DPoP nonce stale — issuing challenge",
);
metrics::counter!(
"oxicloud_dpop_verify_failed_total",
"reason" => "nonce_stale",
)
.increment(1);
return nonce_challenge_response(&nonce_service);
}
None => {
// No nonce presented at all → challenge so the
// NEXT request carries one. The ±30s bootstrap
// window at the verifier means this one still
// succeeded, but we still want the client onto
// the nonce path immediately.
return nonce_challenge_response(&nonce_service);
}
Some(n) => n,
};
// Replay guard — nonce-scoped `jti` dedup. Runs AFTER
// nonce validity so we don't populate the cache with
// entries against a nonce that would 401 anyway (waste
// of pool space; also lets an attacker probe expired
// nonces without pressuring the cache).
if !replay_cache.check_and_record(live_nonce, &verified.jti) {
tracing::info!(
target: "audit",
event = "dpop.replay_detected",
method = %method,
htu = %htu,
jti = %verified.jti,
"👮🏻‍♂️ DPoP proof replayed — same (nonce, jti) seen twice",
);
metrics::counter!("oxicloud_dpop_replay_detected_total").increment(1);
return dpop_verification_failed_response(
DpopVerifyError::SignatureInvalid, // shape-only; audit line carries truth
&nonce_service,
);
}
let response = next.run(request).await;
stamp_current_nonce(response, &nonce_service)
}
Err(err) => {
tracing::info!(
target: "audit",
event = "dpop.verify_failed",
reason = err.reason(),
method = %method,
htu = %htu,
"👮🏻‍♂️ DPoP proof rejected",
);
metrics::counter!(
"oxicloud_dpop_verify_failed_total",
"reason" => err.reason(),
)
.increment(1);
dpop_verification_failed_response(err, &nonce_service)
}
}
}
/// Stamp the currently-fresh nonce onto the outgoing response so
/// the client sees it and caches it for its next request. Called
/// on EVERY successful passthrough — the client's fetch interceptor
/// keeps its cached nonce in sync automatically.
fn stamp_current_nonce(
mut response: Response,
nonce_service: &crate::infrastructure::services::dpop_nonce_service::DpopNonceService,
) -> Response {
let fresh = nonce_service.current_or_rotate();
if let Ok(hv) = HeaderValue::from_str(&fresh) {
response.headers_mut().insert("DPoP-Nonce", hv);
}
response
}
/// Build a `use_dpop_nonce` challenge response — 401 +
/// WWW-Authenticate + DPoP-Nonce carrying a fresh nonce. The SPA
/// fetch interceptor (Gate 4) auto-retries once with the new nonce
/// so users don't experience a visible failure.
///
/// Central counter emission (`oxicloud_dpop_nonce_challenges_issued_total`)
/// lives here rather than at each callsite — every challenge goes
/// through this helper by construction, so one increment covers all
/// three current paths (proof-missing, nonce-missing, nonce-stale).
fn nonce_challenge_response(
nonce_service: &crate::infrastructure::services::dpop_nonce_service::DpopNonceService,
) -> Response {
metrics::counter!("oxicloud_dpop_nonce_challenges_issued_total").increment(1);
let mut resp = AppError::new(
StatusCode::UNAUTHORIZED,
"DPoP nonce required",
"DpopVerificationFailed",
)
.into_response();
resp.headers_mut().insert(
"WWW-Authenticate",
HeaderValue::from_static(r#"DPoP error="use_dpop_nonce""#),
);
let fresh = nonce_service.current_or_rotate();
if let Ok(hv) = HeaderValue::from_str(&fresh) {
resp.headers_mut().insert("DPoP-Nonce", hv);
}
resp
}
/// Build the standardised 401 response for a rejected DPoP proof.
/// Response shape: RFC 9449 §7.1 `WWW-Authenticate: DPoP error="…"`
/// plus OxiCloud's `error_type` JSON body so the SPA can key off it.
/// Also carries a fresh `DPoP-Nonce` so a client whose failure was
/// nonce-shaped (rare after this refactor, but future error paths
/// might need it) can retry immediately.
fn dpop_verification_failed_response(
err: DpopVerifyError,
nonce_service: &crate::infrastructure::services::dpop_nonce_service::DpopNonceService,
) -> Response {
let mut resp = AppError::new(
StatusCode::UNAUTHORIZED,
"DPoP proof verification failed",
"DpopVerificationFailed",
)
.into_response();
// Static: `WWW-Authenticate` schemes stay stable across errors.
// Only the audit `reason` field varies (already emitted).
let www_auth = HeaderValue::from_static(r#"DPoP error="invalid_dpop_proof""#);
resp.headers_mut().insert("WWW-Authenticate", www_auth);
let fresh = nonce_service.current_or_rotate();
if let Ok(hv) = HeaderValue::from_str(&fresh) {
resp.headers_mut().insert("DPoP-Nonce", hv);
}
// Silence unused-parameter lint — err is captured in the audit
// line at the callsite; this fn intentionally maps ALL failures
// to the same client-facing shape (anti-enumeration).
let _ = err;
resp
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderMap;
#[test]
fn external_scheme_host_prefers_forwarded_headers() {
let mut h = HeaderMap::new();
h.insert("x-forwarded-proto", HeaderValue::from_static("https"));
h.insert("x-forwarded-host", HeaderValue::from_static("oxi.example"));
h.insert("host", HeaderValue::from_static("internal:8086"));
assert_eq!(
external_scheme_host(&h),
("https".to_owned(), "oxi.example".to_owned())
);
}
#[test]
fn external_scheme_host_falls_back_to_host_header() {
let mut h = HeaderMap::new();
h.insert("host", HeaderValue::from_static("localhost:5173"));
assert_eq!(
external_scheme_host(&h),
("http".to_owned(), "localhost:5173".to_owned())
);
}
#[test]
fn external_scheme_host_takes_leftmost_of_forwarded_chain() {
// Multiple hops → `X-Forwarded-*` becomes a comma-separated
// list. RFC 7239 says the leftmost is the original client.
let mut h = HeaderMap::new();
h.insert("x-forwarded-proto", HeaderValue::from_static("https, http"));
h.insert(
"x-forwarded-host",
HeaderValue::from_static("oxi.example, internal"),
);
assert_eq!(
external_scheme_host(&h),
("https".to_owned(), "oxi.example".to_owned())
);
}
#[test]
fn external_scheme_host_defaults_when_empty() {
let h = HeaderMap::new();
assert_eq!(
external_scheme_host(&h),
("http".to_owned(), "localhost".to_owned())
);
}
}
+1
View File
@@ -1,6 +1,7 @@
pub mod admin;
pub mod auth;
pub mod csrf;
pub mod dpop;
pub mod locale;
pub mod rate_limit;
pub mod server_status;
+1
View File
@@ -1,5 +1,6 @@
pub mod api;
pub mod errors;
pub mod metrics;
pub mod middleware;
pub mod nextcloud;
pub mod range_requests;
@@ -203,11 +203,15 @@ pub async fn basic_auth_middleware(
// `Arc<CurrentUser>` extension AND `NcSession.user` (the old
// code built the struct, cloned it for the extension, then
// moved the original — 2-3 String allocs per request).
// Nextcloud clients are always unbound — they authenticate
// with app passwords via Basic Auth, no WebCrypto, no DPoP.
// Middleware exempts unbound sessions per Gate 9 design.
let current_user = Arc::new(CurrentUser {
id: user_id,
username: uname,
email,
role,
dpop_jkt: None,
});
// ── Resolve chroot from the Basic Auth drive marker ─────
@@ -342,6 +342,9 @@ pub async fn handle_oidc_login_completion(
username: std::sync::Arc::from(username),
email: std::sync::Arc::from(user_dto.email.as_str()),
role: smol_str::SmolStr::new(&user_dto.role),
// NC login-flow-v2 mints an app password — no browser, no
// WebCrypto, always unbound. DPoP middleware exempts.
dpop_jkt: None,
};
let drives = match state
@@ -553,6 +556,9 @@ pub async fn handle_drive_pick(
username: std::sync::Arc::from(username.as_str()),
email: std::sync::Arc::from(user_dto.email.as_str()),
role: smol_str::SmolStr::new(&user_dto.role),
// NC login-flow-v2 mints an app password — no browser, no
// WebCrypto, always unbound. DPoP middleware exempts.
dpop_jkt: None,
};
let _folder = match state
+46
View File
@@ -780,6 +780,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
app_state.clone(),
require_no_password_change_pending_layer,
))
.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
require_dpop_layer,
))
.layer(axum::middleware::from_fn(csrf_middleware))
.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
@@ -792,6 +796,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
app_state.clone(),
require_no_password_change_pending_layer,
))
.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
require_dpop_layer,
))
.layer(axum::middleware::from_fn(csrf_middleware))
.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
@@ -814,6 +822,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
app_state.clone(),
require_no_password_change_pending_layer,
))
.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
require_dpop_layer,
))
.layer(axum::middleware::from_fn(csrf_middleware))
.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
@@ -851,6 +863,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
app_state.clone(),
require_no_password_change_pending_layer,
))
.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
require_dpop_layer,
))
.layer(axum::middleware::from_fn(csrf_middleware))
.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
@@ -864,6 +880,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
app_state.clone(),
require_no_password_change_pending_layer,
))
.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
require_dpop_layer,
))
.layer(axum::middleware::from_fn(csrf_middleware))
.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
@@ -885,6 +905,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
// change-password, and logout internally so the SPA can
// complete the reset flow — see the middleware doc for the
// allowlist and its rationale.
use oxicloud::interfaces::middleware::dpop::require_dpop_layer;
use oxicloud::interfaces::middleware::user::{
require_internal_user_layer, require_no_password_change_pending_layer,
};
@@ -893,6 +914,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
app_state.clone(),
require_no_password_change_pending_layer,
))
.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
require_dpop_layer,
))
.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
require_internal_user_layer,
@@ -906,6 +931,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
app_state.clone(),
require_no_password_change_pending_layer,
))
.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
require_dpop_layer,
))
.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
require_internal_user_layer,
@@ -919,6 +948,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
app_state.clone(),
require_no_password_change_pending_layer,
))
.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
require_dpop_layer,
))
.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
require_internal_user_layer,
@@ -1359,6 +1392,19 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
tracing::info!("Starting OxiCloud server on http://{}", addr);
// Opt-in Prometheus `/metrics` exporter on a separate listener.
// Installs the recorder BEFORE the main listener starts serving so
// the first request's counter increments are captured (recorder
// install is racy vs first emit — order matters).
if let Some(metrics_addr) = config.metrics_listen
&& let Err(err) = oxicloud::interfaces::metrics::spawn(metrics_addr).await
{
// Fail loudly: operators asked for metrics; not surfacing
// this would hide a misconfigured scrape endpoint.
tracing::error!("Prometheus /metrics setup failed: {err}");
return Err(err);
}
let socket = make_socket(&addr, reuse_port)?;
let listener = tokio::net::TcpListener::from_std(socket.into())?;
+31 -26
View File
@@ -67,40 +67,45 @@ Authorization: Bearer {{admin_token}}
HTTP 200
[Asserts]
# Names — the four scheduler tenants.
# Presence asserts — each scheduled tenant we EXPECT must be
# listed. Failure of any one means the registration wiring was
# dropped; adding a new tenant elsewhere never breaks these.
#
# Deliberately shape-not-cardinality: an earlier version of this
# step hardcoded `count == 12` / `count == 4` totals, which broke
# on EVERY new registration in unrelated PRs. Duplicate-name
# registration is already trapped at boot with a panic (see
# `TrashCleanupService::register` — the registry rejects same-name
# reinserts loudly), so the total-count gate added nothing that
# `contains` didn't already cover, at the cost of drive-by
# maintenance every quarter.
jsonpath "$[*].name" contains "trash_cleanup"
jsonpath "$[*].name" contains "usage_reconcile"
jsonpath "$[*].name" contains "dedup_gc"
jsonpath "$[*].name" contains "grant_cleanup"
jsonpath "$[*].name" contains "session_cleanup"
# Scheduled jobs' interval_ms values, in whatever order:
# TrashCleanup → 24 h = 86_400_000 ms
# GrantCleanup → 24 h = 86_400_000 ms
# StorageReconcile → 600 s = 600_000 ms
# Recursive descent collects all interval_ms values across the
# array; `contains` doesn't care about order.
# Schema asserts — the response shape itself. Every scheduled
# tenant carries an `interval_ms` value; recursive-descent
# `contains` on the specific intervals we ship pins the
# non-default cadences without caring about the ordering or the
# total number of scheduled tenants.
# TrashCleanup / GrantCleanup / SessionCleanup → 24 h = 86_400_000 ms
# StorageReconcile → 600 s = 600_000 ms
jsonpath "$..interval_ms" contains 86400000
jsonpath "$..interval_ms" contains 600000
# On-demand job (`dedup_gc`) has no interval_ms field, so the
# total count of interval_ms values is 3, not 4. Combined with
# the four-name check above, this pins the on-demand-omission
# behaviour without hitting the single-match filter trap.
jsonpath "$..interval_ms" count == 3
# Every entry carries a `running` bool — same aggregate primitive.
# Count matches the registered-tenant count: 4 Part 1 periodics
# (trash_cleanup, usage_reconcile, dedup_gc, grant_cleanup) + 5
# Part 2 recoverables (drives_consistency, folders_consistency,
# files_consistency, blobs_consistency, backend_consistency —
# wrapped by RecoverableAdapter so they appear here alongside the
# periodics) + 1 coordinator (consistency_batch — a plain
# JobHandler that dispatches every registered `*_consistency`) +
# 2 on-demand admin ops (backend_migration — the readonly-mode +
# cutover backend swap; backend_rotate — K3, in-place per-blob
# format normalisation, no readonly).
# Bump when a new tenant registers.
jsonpath "$..running" count == 12
# Floor asserts — catastrophic-regression guard, not a fragile
# total. Bump the floors in lockstep with the `contains "..."`
# presence checks above: 5 named tenants total (trash_cleanup,
# usage_reconcile, dedup_gc, grant_cleanup, session_cleanup) →
# ≥ 5 `running` entries; 4 of those are scheduled (dedup_gc is
# on-demand and carries no `interval_ms`) → ≥ 4 `interval_ms`
# values. New unrelated tenants only PUSH the actual counts UP,
# never below these floors, so they don't break on drive-by
# additions.
jsonpath "$..running" count >= 5
jsonpath "$..interval_ms" count >= 4
jsonpath "$[*].name" contains "drives_consistency"
jsonpath "$[*].name" contains "folders_consistency"
jsonpath "$[*].name" contains "files_consistency"
+35
View File
@@ -144,6 +144,41 @@ HTTP 200
[Asserts]
jsonpath "$.email" == "{{email}}"
jsonpath "$.username" == "{{username}}"
[Captures]
admin_user_id: jsonpath "$.id"
# ─────────────────────────────────────────────────────────────
# Step 7b — SessionOrigin stamping regression. Every login handler
# records HOW the session was minted; the admin panel
# surfaces that. This step proves TWO origins land
# correctly on the same account:
# * `password` — from Steps 1-2 legacy /api/auth/login
# * `magic_link` — from Step 6 magic-link redemption
# A missing/drifted stamp (e.g. a handler forgetting to
# pass the SessionOrigin arg after a refactor) would
# surface here as `unknown` instead of the expected value.
#
# `include_revoked=true` because Step 1 and Step 2 both
# create sessions and the second may have rotated the
# first out — we want ALL of admin's sessions in-frame.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/admin/sessions?user_id={{admin_user_id}}&include_revoked=true
Authorization: Bearer {{alice_token}}
HTTP 200
[Asserts]
# `body contains` rather than a jsonpath collection predicate because
# Hurl unwraps single-element `[*]` results to scalars — see the same
# pattern in tests/oidc/oidc.hurl Step 8b for the full reasoning.
body contains "\"origin\":\"password\""
body contains "\"origin\":\"magic_link\""
# `access_token_expiry_secs` also served for the SPA's revoke-lag
# notice. Belt-and-braces with tests/oidc/oidc.hurl Step 8b (same
# handler, both suites verify the field ships so a shape change
# would fail at least one of them).
jsonpath "$.access_token_expiry_secs" isInteger
jsonpath "$.access_token_expiry_secs" > 0
# ─────────────────────────────────────────────────────────────
+24
View File
@@ -248,4 +248,28 @@ OPAQUE_HELPER_USERNAME="$username" \
OPAQUE_HELPER_PASSWORD="$password" \
"$OPAQUE_HELPER_BIN" || die "OPAQUE crypto handshake failed"
# ── 6. DPoP wire protocol — the parts Hurl can't drive ──────────────────
# Each proof carries a fresh jti, current iat, htm/htu matching the
# exact request, an ES256 signature, and a threaded nonce — none of
# which a declarative .hurl template can compute. See
# `src/bin/dpop-hurl-helper.rs` for the scenario matrix (happy path,
# wrong htm/htu/alg/typ, stale nonce, replay, malformed, fail-open
# when the session is unbound). Runs against the SAME server target
# the OPAQUE helper used — but the server config must set
# `OXICLOUD_DPOP_MODE=opportunistic` (or `required`) or the middleware
# is a pass-through and every failure scenario silently 200s.
DPOP_HELPER_BIN="$REPO_ROOT/target/$BUILD_TARGET/dpop-hurl-helper"
if [[ ! -x "$DPOP_HELPER_BIN" ]]; then
log "Building dpop-hurl-helper ($BUILD_TARGET)..."
case "$BUILD_TARGET" in
debug) (cd "$REPO_ROOT" && cargo build --bin dpop-hurl-helper 2>&1 | tail -n 20) || die "dpop-hurl-helper build failed" ;;
release) (cd "$REPO_ROOT" && cargo build --release --bin dpop-hurl-helper 2>&1 | tail -n 20) || die "dpop-hurl-helper build failed" ;;
esac
fi
log "Running DPoP wire-protocol helper..."
DPOP_HELPER_BASE_URL="$base_url" \
DPOP_HELPER_USERNAME="$username" \
DPOP_HELPER_PASSWORD="$password" \
"$DPOP_HELPER_BIN" || die "DPoP wire-protocol test failed"
log "All tests passed."
+24
View File
@@ -0,0 +1,24 @@
# Intentionally empty config file for test-server startup.
#
# Used as `--config tests/common/empty.env` by the Playwright
# webServer scripts (`tests/e2e/start-server*.sh`). Two properties
# it gives us that a bare `cargo run` doesn't:
#
# 1. Loading an empty file overrides nothing, so every var
# Playwright places in the child process env (SERVER_PORT,
# STORAGE_PATH, RUST_LOG, OPAQUE_MODE, DPOP_MODE, whatever
# per-suite overrides land here) reaches the server intact.
#
# 2. Passing `--config <anything>` in main.rs takes the
# `Some(path)` branch and skips the fallback
# `dotenvy::dotenv()` probe of `$CWD/.env`. Without this a
# developer's local `.env` (typical:
# `OXICLOUD_METRICS_LISTEN=127.0.0.1:9090`) leaks into the
# test server via CWD auto-load and clashes with whatever
# the dev is already running.
#
# Hurl API tests use `tests/common/server.env` for --config
# instead — they source it in shell first, so file-wins doesn't
# matter there. Only Playwright needs the empty-file trick
# because it applies per-suite env overrides that would otherwise
# be clobbered by server.env's shared values.
+16 -1
View File
@@ -5,7 +5,11 @@
DATABASE_URL=postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test
OXICLOUD_DB_CONNECTION_STRING=postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test
OXICLOUD_STATIC_PATH=./static
# `OXICLOUD_STATIC_PATH` intentionally NOT set — every remaining
# test suite either serves the built SvelteKit SPA from
# `static-dist/` (coverage suite via `start-server-spa.sh`) or
# doesn't need static assets at all (Hurl API / WebDAV / CalDAV
# suites). The legacy `./static` vanilla frontend was retired.
OXICLOUD_JWT_SECRET=test-secret-do-not-use-in-prod-minimum-32-chars
OXICLOUD_ENABLE_AUTH=true
OXICLOUD_ENABLE_TRASH=true
@@ -145,3 +149,14 @@ OXICLOUD_AUTH_OPAQUE_KSF_MEMORY_KIB=8
OXICLOUD_AUTH_OPAQUE_KSF_ITERATIONS=1
OXICLOUD_AUTH_OPAQUE_KSF_PARALLELISM=1
# DPoP (RFC 9449) — required mode surfaces every verifier /
# nonce / replay bug as a hard 401 (opportunistic would swallow
# some cases as warnings). Safe today because unbound sessions —
# `session.dpop_jkt IS NULL`, which is where the test admin's
# legacy-login lands — are still exempted at the middleware; the
# session-level enforcement flip is Gate 9. Once that arrives,
# every bind-time downgrade path also gets caught here without
# a config change. `dpop-hurl-helper` needs this on or the
# middleware is a pass-through and its scenarios all silently 200.
OXICLOUD_DPOP_MODE=required

Some files were not shown because too many files have changed in this diff Show More