Skip to content

[FEAT] OIDC relying party on the server and SDK login flow - #459

Open
justin13888 wants to merge 43 commits into
feat/server-binary-config-operator-commands-401from
feat/oidc-relying-party-407
Open

justin13888 wants to merge 43 commits into
feat/server-binary-config-operator-commands-401from
feat/oidc-relying-party-407

Conversation

@justin13888

@justin13888 justin13888 commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Description

OIDC relying party on the server (S-N1) and the SDK half of the OIDC login flow (S-N2): authorization code + PKCE against an external identity provider, discovery and JWKS handling, ID-token claim checks, a typed ceremony store for the pending authorization, account resolution keyed on (issuer, subject), and the two routes that drive it, mounted inside the protocol gate. Stacked on #435 (W-SERVE) with #453 (W-HEADERS) merged in.

Every slice of the plan has landed, review rounds 1 through 4 are answered, and every gate is green at head f1ab7d2a.

Related Issues

Closes #407. Defers the durable adapters to #460 and the CLI remainder of S-N2 to #461.

Contributor Checklist

  • I agree to the Contributor License Agreement for this and future contributions.
  • My code follows the project's style guidelines according to CONTRIBUTING.md.
  • Tests pass
  • No sensitive info / secrets
  • Docs updated if needed

Summary

Commit plan (Conventional Commits, each leaving the tree coherent):

  1. b63cf556 feat(server): add the OIDC authorization ceremony storeOidcState/OidcNonce/PkceVerifier/AuthorizationCode secret ids, the OidcAuthorizationStore port with PendingAuthorization and a fixed ten-minute TTL, the in-memory adapter, two conformance rows (single-use consume, expiry with the store).
  2. 302da2f0 + 671f83e6 feat(server): verify OIDC ID tokens as a pure functionauth::oidc::claims::verify_id_token, signature by jsonwebtoken, every claim check Capsule's, judged against an injected instant; eighteen negative cases.
  3. c21188d9 feat(server): add the OIDC identity-provider port and its HTTP adapterIdentityProvider (two methods) with HttpIdentityProvider and the Disabled null object; discovery cached a day with the issuer mix-up defence; JWKS refetched on an unknown kid, floored at one a minute; form-encoded exchange (Basic when a secret is configured); RedirectPolicy; FederatedAccounts with InMemoryFederatedAccounts; OIDC_* config; boot wiring (lazy, durable refusal by name); tests/support/idp.rs mock provider; nine wire tests.
  4. c27e86b1 feat(server): mount the OIDC authorize and callback routesPOST /v1/auth/oidc/{authorize,callback} inside ProtocolGate; seven error.auth.oidc_* keys and mise run i18n; server-info auth.oidc | null; openapi.json regenerated (+2 operations); twelve route tests over the double and the real adapter.
  5. 2c41ec05 feat(sdk): begin and complete an OIDC loginAuthClient::begin_oidc_login / complete_oidc_login, OidcAuthorization, typed AuthError refusals on the catalog codes.
  6. 73977f05 docs(design): record the OIDC relying party and ship a dex development providerauthentication.md section, dependencies.md rows, dex under --profile oidc in capsule-server/compose.yaml, SLICES.md S-N1 done* / S-N2 part.
  7. c080a44f test(server): walk every declared response of the OIDC operations — both paths in the conformance walk's 413 list and an additive oidc_block producing the fourteen declared responses (decision 10).

Review round 1 on #459 added seven commits, each one routed decision or hardening finding:

  1. 273f42f9 fix(server): harden the OIDC token and discovery checks — F3 (the loopback carve-out no longer admits a plain-HTTP endpoint under a non-loopback issuer, and localhost is not silently admitted anywhere: is_loopback_issuer demands http plus an IP literal), F6 (iss, kid and the token endpoint's error_description bounded at 255 bytes before they reach a log line or an error), F9 (a non-empty crit header is refused), F10 (a multi-aud token without azp is refused, Core §3.1.3.7 rule 4), decision 12 (the JWKS cache gains a one-hour MAX_AGE ceiling beside its one-minute refetch floor).
  2. 91cf7a0c feat(server): bound the OIDC authorize by budget and by store ceiling — decision 17: budgets::OIDC_AUTHORIZE (60/min per redirect host, CounterKey::OidcAuthorize, the S-C32 limiter) answers 429 before anything is generated; InMemoryOidcAuthorizations purges expired records on every begin and refuses past PENDING_AUTHORIZATION_CEILING (10 000) with a 503; both new responses declared and walked.
  3. 6512e70a fix(server): reserve a federated address only when the provider verified it — decision 11 (an address asserted without email_verified neither reserves nor collides) and decision 15 (the lockout the OIDC door does not consult, recorded on open_session_for).
  4. d294ad76 fix(server): make loopback redirects opt-in and hold the redirect URL to the scheme rule — decision 14 (OIDC_ALLOW_LOOPBACK_REDIRECT defaults off), F11 (the broken line continuation in the issuer fault's message repaired; OIDC_REDIRECT_URL held to the issuer's scheme rule), F8 (deny_unknown_fields on the callback body; document regenerated).
  5. d73f235c feat(server): trust a private CA for the identity provider — decision 18: OIDC_CA_BUNDLE, a PEM path read at boot into the relying party's reqwest client, refused by name when unusable.
  6. 5e4d2e8a refactor(sdk): drive the OIDC login through the generated client — decision 16: both legs go through Spargen's rest:: client and its typed error enums; the hand-written request building and response parsing is gone.
  7. e54cfb59 docs(design): record the round-1 OIDC decisions and document the six settings — decision 13: authentication.md names server: Valkey adapter for OidcAuthorizationStore and Postgres adapter for FederatedAccounts #460 for the durable gap in its own bullet and states the two properties the fixture double encodes rather than the server ships; .env.example gains the six settings.

Merges: 8afeb9ec (#435 at 459e8af3), 1d471635 (#453 at 7526395b), e977c096 (#453 at 892f7575), 62376d81 (#453 final at 032b6af2).

Review round 2 confirmed every round-1 citation and left one High finding, delivered as three commits:

  1. 36c73417 fix(server): charge the OIDC authorize budget under a bounded key — decision 19, finding F-A.
  2. d5b0afe5 fix(server): purge and bound the in-memory counter windows — decision 20.
  3. 739845d9 feat(server): give the OIDC at-capacity refusal its own code — decision 21.

Review round 3 confirmed decisions 19–21 at their cited lines and left one Medium, cross-surface finding, delivered as four commits:

  1. 856e7313 fix(server): bound the enrollment redemption key before charging it — decision 23.
  2. 04192ea0 fix(server): give each counter key its own ceiling — decision 22.
  3. 1a5b9e93 docs(design): record the partitioned counter ceiling and the code shape check.
  4. c2a59e8a test(server): fill the drop partition to its shipped ceiling — decision 22's second test.

Review round 4 confirmed decisions 22 and 23 and left one Medium finding, delivered as two commits:

  1. 47ed85a2 fix(server): tell a caller a full limiter partition is not an outage — decision 25.
  2. b9e5b8ec docs(server): name the issue the shared counter lock is deferred to — decision 27.

Review round 4's follow-up closed the lane with one commit:

  1. f1ab7d2a test(server): pin that only the ceiling reads as a capacity refusal — decision 28. Decision 29 is a record correction and changes no code.

Validation

All run inside the worktree, in the foreground. Aggregate gates at head c080a44f; the focused checks at the commit that introduced them.

Command Outcome Classification
cargo nextest run -p capsule-server --lib store:: 39 passed
cargo nextest run -p capsule-server --lib oidc:: 18 passed (claims), later 75 passed with config:: boot::
cargo nextest run -p capsule-server --test oidc 21 passed
cargo nextest run -p capsule-sdk --lib auth:: 20 passed
mise run i18n-check generated files are up to date
mise run openapi-check-kynos document is up to date
mise run check-docs-truth 474 links, 98 endpoint citations, 119 paths — all resolve
mise run check-md 0 issues in 168 files
mise run check-docs format, lint, 65 tests, build — all pass
mise run check-rust exit 0 (format, clippy, rustdoc, i18n, i18n-guard, openapi, architecture, licences, readme, build, wasm, ffi, bindings, examples)
cargo nextest run -p capsule-server --test conformance (at c080a44f) 9 passed
mise run test-rust (at c080a44f) exit 0 — cargo nextest run --workspace 1888 run, 1888 passed; -p capsule-core --features ffi 729 passed; -p capsule-sdk --features ffi 165 passed
mise run test-rust (at 73977f05, before the conformance block) 1 failed / 1887 passed: every_declared_response_is_exercised listed the 14 declared responses of the two new operations its walk never produced caused, resolved by c080a44f once the orchestrator widened the manifest (decision 10)
podman compose -f capsule-server/compose.yaml --profile oidc config parses; the inline configs.dex_config.content is honoured
cargo nextest list counts after each test-touching commit 39 → 57 (lib) → 75 (lib) + 9 (oidc) → 21 (oidc); never dropped
cargo nextest run -p capsule-server --lib oidc:: config:: store:: counter:: boot:: (at e54cfb59) 136 passed
cargo nextest run -p capsule-server --test oidc --test conformance (at e54cfb59) 33 passed
cargo nextest run -p capsule-sdk --lib auth:: (at e54cfb59) 21 passed
mise run format-check-rust (at e54cfb59) exit 0
mise run lint-check-rust (at e54cfb59) exit 0
mise run build-rust (at e54cfb59) exit 0
mise run doc-check-rust (at e54cfb59) exit 0
mise run i18n-check (at e54cfb59) generated files are up to date
mise run i18n-guard (at e54cfb59) 74 codes match; no hardcoded literals
mise run openapi-check-kynos (at e54cfb59) document is up to date
mise run architecture-check (at e54cfb59) boundaries intact
mise run license-check (at e54cfb59) licenses ok
mise run translate-readme-check (at e54cfb59) 12 translations up to date
mise run lint-check-ffi (at e54cfb59) exit 0
mise run verify-examples (at e54cfb59) 16 passed; smokes passed
mise run build-check-wasm (at e54cfb59) exit 0
mise run build-ffi (at e54cfb59) exit 0
mise run gen-bindings (at e54cfb59, CARGO_TARGET_DIR unset — the task hardcodes target/) bindings written, surface check passed, working tree clean afterwards
mise run test-rust (at e54cfb59) exit 0 — 1901 passed, 729 passed (capsule-core --features ffi), 166 passed (capsule-sdk --features ffi)
mise run check-docs-truth (at e54cfb59) 474 links, 99 endpoint citations, 119 paths — all resolve
mise run check-md (at e54cfb59) 0 issues in 168 files
mise run check-docs (at e54cfb59) format, lint, tests, build — all pass
mise run cli-surface-check task is not defined in this worktree's mise.toml; check-rust on this base does not aggregate it unavailable
cargo nextest run -p capsule-server --test oidc --test conformance (at 739845d9) 35 passed
cargo nextest run -p capsule-server --lib counter:: (at 739845d9) 11 passed
cargo nextest run -p capsule-server (at d5b0afe5) 843 passed
decision 19's test against the pre-fix ordering (admits_redirect short-circuited to true) 1 failed — observed 60 counter keys where 1 is required expected; the test is a real regression test, not a tautology
mise run format-check-rust (at 739845d9) exit 0
mise run lint-check-rust (at 739845d9) exit 0
mise run build-rust (at 739845d9) exit 0
mise run doc-check-rust (at 739845d9) exit 0
mise run i18n-check (at 739845d9) generated files are up to date
mise run i18n-guard (at 739845d9) 74 codes match; no hardcoded literals. error.auth.oidc_at_capacity is reported as a catalog code no Swift screen names — non-fatal by that gate's own asymmetry, and correct: it localizes through ErrorCode.unknown(_) and no client-side affordance is owed for it
mise run openapi-check-kynos (at 739845d9) document is up to date (the code is a problem extension value, not a schema member, so the contract is unchanged)
mise run architecture-check (at 739845d9) boundaries intact
mise run license-check (at 739845d9) licenses ok
mise run build-check-wasm / build-ffi / lint-check-ffi / translate-readme-check / verify-examples (at 739845d9) exit 0 each
mise run gen-bindings (at 739845d9, CARGO_TARGET_DIR unset) exit 0; working tree clean afterwards
mise run test-rust (at 739845d9) exit 0 — 1906 passed (up from 1901: two OIDC wire tests, three counter tests), 729 passed (capsule-core --features ffi), 166 passed (capsule-sdk --features ffi)
mise run check-docs-truth (at 739845d9) 474 links, 99 citations, 119 paths resolve
mise run check-md (at 739845d9) 0 issues
mise run check-docs (at 739845d9) format, lint, tests, build pass
mise run cli-surface-check still not defined in this worktree's mise.toml unavailable — nothing substituted
cargo nextest run -p capsule-server --lib counter:: (at f1ab7d2a) 17 passed
decision 28's guards against a dropped store check (is_at_capacity matching any Rejected) 2 failed — a Rejected { store: "something-else" } read as a capacity refusal expected; both are guards, not restatements
mise run format-check-rust / lint-check-rust (at f1ab7d2a) exit 0
mise run test-rust (at f1ab7d2a) exit 0 — 1919 passed (up from 1917: decision 28's two guards), 729 passed (capsule-core --features ffi), 166 passed (capsule-sdk --features ffi)
cargo nextest run -p capsule-server --lib counter:: enroll:: oidc:: (at c2a59e8a) 57 passed
cargo nextest run -p capsule-server --test enroll --test share --test drops --test oidc --test conformance --test limits (at c2a59e8a) 87 passed
decision 22's cross-surface test against a simulated shared ceiling (occupancy summed across partitions) 1 failed — share_link denied by a full drop_link partition expected; the test is a real regression test
decision 19's test against the pre-fix ordering, re-confirmed 1 failed — 60 counter keys where 1 is required expected
mise run format-check-rust (at c2a59e8a) exit 0
mise run lint-check-rust (at c2a59e8a) exit 0
mise run doc-check-rust (at c2a59e8a) exit 0
mise run i18n-check (at c2a59e8a) generated files are up to date
mise run i18n-guard (at c2a59e8a) exit 0
mise run openapi-check-kynos (at c2a59e8a) document is up to date
mise run architecture-check (at c2a59e8a) boundaries intact
mise run license-check (at c2a59e8a) licenses ok
mise run translate-readme-check (at c2a59e8a) 12 translations up to date
mise run build-rust / build-check-wasm / build-ffi / lint-check-ffi / verify-examples (at c2a59e8a) exit 0 each
mise run gen-bindings (at c2a59e8a, CARGO_TARGET_DIR unset) exit 0; working tree clean afterwards
mise run test-rust (at c2a59e8a) exit 0 — 1914 passed (up from 1906: five counter tests, two enrollment wire tests, one enrollment unit trio counted as three), 729 passed (capsule-core --features ffi), 166 passed (capsule-sdk --features ffi)
mise run check-docs-truth (at c2a59e8a) 474 links, 99 citations, 119 paths resolve
mise run check-md (at c2a59e8a) 0 issues
mise run check-docs (at c2a59e8a) format, lint, tests, build pass
mise run cli-surface-check still not defined in this worktree's mise.toml (mise tasks ls --all matches nothing) unavailable — nothing substituted
cargo nextest run -p capsule-server --lib counter:: enroll:: oidc:: openapi:: (at b9e5b8ec) 61 passed
cargo nextest run -p capsule-server --test enroll --test share --test drops --test oidc --test conformance --test limits --test problem (at b9e5b8ec) 94 passed
decision 25's three capacity tests against the pre-fix mapping (capacity_refusal forced to None) 3 failed — "expected 429 Too Many Requests, received 500 Internal Server Error" on drop, share and enrolment expected; all three are real regression tests
mise run format-check-rust (at b9e5b8ec) exit 0
mise run lint-check-rust (at b9e5b8ec) exit 0
mise run doc-check-rust (at b9e5b8ec) exit 0
mise run i18n-check (at b9e5b8ec) generated files are up to date
mise run i18n-guard (at b9e5b8ec) exit 0; the three new *_at_capacity codes are reported as catalog codes no Swift screen names, which that gate treats as non-fatal by design
mise run openapi-check-kynos (at b9e5b8ec) document is up to date (regenerated: retry_after on five throttled operations)
mise run architecture-check (at b9e5b8ec) boundaries intact
mise run license-check (at b9e5b8ec) licenses ok
mise run translate-readme-check (at b9e5b8ec) 12 translations up to date
mise run build-rust / build-check-wasm / build-ffi / lint-check-ffi / verify-examples (at b9e5b8ec) exit 0 each
mise run gen-bindings (at b9e5b8ec, CARGO_TARGET_DIR unset) exit 0; working tree clean afterwards
mise run test-rust (at b9e5b8ec) exit 0 — 1917 passed (up from 1914: the three capacity wire tests), 729 passed (capsule-core --features ffi), 166 passed (capsule-sdk --features ffi)
mise run check-docs-truth (at b9e5b8ec) 474 links, 99 citations, 119 paths resolve
mise run check-md (at b9e5b8ec) 0 issues
mise run check-docs (at b9e5b8ec) format, lint, tests, build pass
mise run format-check-web (first invocation) failed while bunx was resolving and downloading biome on first use; passed on immediate re-run with no change to the tree flaky (first-run dependency fetch)
mise run check-webbuild-web rsbuild: command not found; capsule-web/node_modules is absent in this worktree, so the web build has never been installable here. Its siblings format-check-web, lint-check-web, build-wasm and share-kat all pass. Not run with CARGO_TARGET_DIR set — build-wasm hardcodes target/, like gen-bindings unavailable (environment); nothing substituted
mise run cli-surface-check still not defined in this worktree's mise.toml unavailable — nothing substituted

Risks and rollout

Every slice is dark unless OIDC_ISSUER is set; backing out is unsetting one variable. The openapi.json and locale additions are additive. No persisted data changes for a deployment that never enables it.

Decisions taken

Issue 407 - auth: OIDC relying party on the server and SDK/CLI login flows (S-N1, S-N2)
Plan:     v1 (planned against 99dd4bc8; executed on the head of lane #401's branch)
Branch:   feat/oidc-relying-party-407
Base:     feat/server-binary-config-operator-commands-401 (head of the W-SERVE PR), stacked; the PR targets that branch. Both OIDC routes are ProtocolGate'd per #404 — if #404's branch is open when this lane starts, merge it into the worktree first (merge commit) so the routes mount inside the gated Group; record which.
Worktree: /var/mnt/scratch/golem/dev/Capsulsaurus/Capsule.worktrees/Capsule-feat-oidc-relying-party-407
Cause:    - (feature)
Touches:  capsule-server/src/auth/oidc/{mod,claims,discovery,jwks,provider,accounts}.rs (new), capsule-server/src/routes/{oidc.rs (new), mod.rs, well_known.rs}, capsule-server/src/auth/mod.rs (OidcContext), capsule-server/src/{app.rs, lib.rs (group 2 mounts), config.rs, boot.rs}, capsule-server/src/store/{mod,ids,ceremony,memory,conformance}.rs (OidcAuthorizationStore, OidcState, PendingAuthorization), capsule-server/src/discovery/mod.rs (AuthEndpoints.oidc), capsule-server/tests/{oidc.rs (new), support/idp.rs (new mock IdP)}, capsule-server/Cargo.toml (reqwest: default-features = false, features = ["json","rustls-tls"]), Cargo.toml (reqwest into [workspace.dependencies]), capsule-server/openapi.json (regenerated: +2 operations, ServerInfoResponse.auth.oidc), capsule-sdk/src/auth.rs (begin_oidc_login / complete_oidc_login; AuthEndpoints fields; AuthError mappings), locales/en.json (error.auth.oidc_{not_configured,redirect_invalid,state_invalid,exchange_failed,token_invalid,address_taken,unavailable}) + `mise run i18n` outputs, root compose.yaml (dex dev IdP — coordinate: #401 creates capsule-server/compose.yaml; add the dex service THERE rather than a root file), capsule-docs/src/content/docs/design/{dependencies.md (amend HTTP-client row scope; new OIDC RP row), authentication.md (linking policy, TOTP-on-OIDC, redirect allow-list)}, SLICES.md (rows/blocks S-N1, S-N2 ONLY)
Will not: change local auth; add a device-authorization grant; add a CLI verb; link accounts by email; request the profile scope; add any crate to Cargo.lock
Lane:     serialised behind #401 and #404. Forecast collisions: openapi.json, tests/support/mod.rs, store/ceremony.rs (with #405? no — #405 does not touch ceremony), locales/en.json (with #405: distinct keys), compose file (with #401: append a service).
Settled:  #401 owns env parsing in config.rs and wiring in boot.rs::assemble. #404: versioned operations sit behind ProtocolGate. Base = head of PR #418 → #399 → #401.

Decisions taken.

1. Deliverable boundary
   Taken:    Server relying party (S-N1, authorization code + PKCE, discovery + JWKS + claim checks) + the SDK login flow (the S-N2 half needing no UX design).
   Rejected: the whole of #407 including `capsule auth login --oidc` - SLICES.md:4893 requires both a loopback listener and the device-code flow; authentication.md:78 names only authorization-code + PKCE and settles neither the loopback port policy nor the browser-open policy.
   Reverses: add capsule-cli/src/oidc.rs (loopback listener), an --oidc arm on AuthCommands::Login, cli.auth.login.oidc_* keys, and routes/oidc.rs::{begin,poll}_device_authorization with an OidcDeviceStore.
   Filed:    the lane files "auth: CLI OIDC login — loopback listener and device authorization grant (S-N2 remainder)" and links it. The orchestrator may admit it as an extra entry if capacity remains.

2. OIDC crate: hand-written RP over the existing jsonwebtoken, not openidconnect
   Taken:    jsonwebtoken 10.4.0 (workspace dep, aws_lc_rs: JwkSet, JwkSet::find, DecodingKey::from_jwk) plus ~400 hand-written lines for discovery, the token exchange and claim checks; reqwest promoted to capsule-server (already in Cargo.lock via the SDK). No crate added to Cargo.lock.
   Rejected: openidconnect 4.0.1 - its manifest declares chrono (banned; exhaustive exception list at dependencies.md:24 does not include it), the log facade (banned; S-F6 removes it), rsa 0.9.2 (RUSTSEC-2023-0071, unfixed; deny.toml runs licences only), and duplicate majors of base64 and thiserror.
   Reverses: cargo add openidconnect --no-default-features -F reqwest,rustls-tls; replace discovery/jwks/claims with CoreClient; add a deny.toml advisory-ignore and a chrono exception row.

3. Where the auth-code state, nonce and PKCE verifier live
   Taken:    A new typed ceremony port OidcAuthorizationStore in store/ceremony.rs, fixed 10-minute ttl(), destructive consume (replayed state unrepeatable); OidcState in store/ids.rs (128-bit random, base64url); in-memory adapter + conformance rows.
   Rejected: a field/method on AuthStateStore (owns durable session records + record/index atomicity); a generic TTL bag (refused by AGENTS.md).
   Reverses: move PendingAuthorization onto AuthStateStore and delete the port.

4. Account linking and the second factor on the OIDC path
   Taken:    A one-method port FederatedAccounts::resolve_or_create keyed on (issuer, subject); first login creates a password-less account whose credential is null (AccountDirectory::authenticate must return Refused for it — a conformance row); an IdP-asserted address already owned locally → 409 error.auth.oidc_address_taken, never a link; a confirmed TOTP enrollment returns the same 202 challenge the password path does.
   Rejected: auto-linking by the email claim (account takeover by anyone who can set an email at the IdP; cf. authentication.md:71); bypassing TOTP (reproduces the S-C55 defect for zero saving); extending AccountRegistry (its docs: "a port with a second method is a port that will have six"; create takes a password an OIDC account must not have).
   Reverses: fall back to an address lookup in resolve_or_create; drop the enrollments().read() call in the callback.

5. Sibling merges (taken by the lane)
   Taken:    Merged origin/feat/server-binary-config-operator-commands-401 at 459e8af3605d6c706c0d0ebeca2474309cb113fd (the W-SERVE final head) and origin/fix/protocol-headers-every-route-404 at 7526395b820efc7f02d3332a43073dad85e33a9a (W-HEADERS) as merge commits before the first edit; then W-HEADERS' repair head 892f7575236c45b14c850ab9d477021ce9cdf460 (the gate split into ProtocolGate for non-safe methods and ProtocolReadGate for GET/HEAD) after slice 3, and its final head 032b6af2d324a3d2e543eba3200a35bbcf59e5fe (web test only) after slice 4. Both OIDC operations are POST and mount in the ProtocolGate group; openapi.json was regenerated after the routes landed, never merged as JSON.
   Rejected: rebasing onto either branch (rewrites the lane's history and the base PR's), or cherry-picking the interceptor pair (forks the protocol gate).
   Reverses: `git revert -m 1` of the two merge commits.

6. The conformance harness accessor for the new store is optional for now (taken by the lane)
   Taken:    `store::conformance::Harness::oidc_authorizations()` returns `Option<&dyn OidcAuthorizationStore>` with a default of `None`; the two OIDC rows panic on `None` when driven individually and `run_all` skips them.
   Rejected: a required accessor - it would stop the Valkey harness W-VALKEY (#454) is adding from compiling on merge until its OIDC adapter exists, which #460 owns.
   Reverses: drop the `Option` and the `run_all` guard when the Valkey adapter lands.

7. "Not configured" is a provider answer, and the callback declares no 404 (taken by the lane)
   Taken:    `Disabled` is a null-object `IdentityProvider` (and `FederatedAccounts`) an unconfigured deployment runs with, answering `ProviderError::NotConfigured`; the authorize maps it to `404 error.auth.oidc_not_configured`. The callback declares no 404: an unconfigured deployment holds no pending ceremony, so a callback there is `401 error.auth.oidc_state_invalid`. `server-info` publishes `auth.oidc: null` via `ServerInfo::with_oidc()` being absent.
   Rejected: `Option<RelyingParty>` on `OidcContext` - the conformance walk records responses per client against one immutable context, so the 404 and the 200 could never both be produced through one client, and a 404 on the callback would be a response production can never render (the plan's table listed it; the record is silent).
   Reverses: make `OidcContext` carry `Option<...>`, drop `Disabled`, and declare 404 on the callback.

8. The development profile's federated accounts hold their own rows (taken by the lane)
   Taken:    `auth::oidc::InMemoryFederatedAccounts` keeps its own `(issuer, subject)` and address maps; `boot.rs` wires it under `--memory`. Under that profile an identity whose address matches a password account is not refused with 409 and an OIDC account has no profile row - both recorded in the adapter docs and in #460 as the shape to fold into `InMemoryAccounts`.
   Rejected: implementing `FederatedAccounts` on `auth::accounts_memory::InMemoryAccounts` (shared rows, a nullable credential) - that file is outside the lane's manifest; refusing `OIDC_ISSUER` under `--memory` too - it would make the dex service unusable and the development loop the compose file exists for impossible.
   Reverses: `impl FederatedAccounts for InMemoryAccounts` with `Option<String>` credentials and delete `InMemoryFederatedAccounts` (#460, item 3).

9. Manifest gap: `capsule-server/tests/conformance.rs` (raised by the lane)
   Taken:    Everything inside the manifest was delivered and published first; `tests/conformance.rs` was left untouched and the gap reported. `every_declared_response_is_exercised` walks every operation in the document through one client and asserts every declared response was produced, so any lane that adds an operation has to extend that walk; the plan's `Touches:` line did not list the file.
   Rejected: widening silently; leaving the routes unmounted to keep the walk green (the SDK would target routes the server does not serve).
   Reverses: n/a - superseded by decision 10.

10. Manifest widened by the orchestrator: `capsule-server/tests/conformance.rs`
   Taken:    The file joins the manifest as a file every operation-adding lane must touch. Both OIDC paths join the walk's 413 list and an additive `oidc_block` after the second factor's produces every other declared response through the fixture's provider double and its switches; the census classifies both POSTs as gated writes because they mount in the `ProtocolGate` group. `test-rust` is green at `c080a44f`.
   Rejected: shipping `test-rust` red for another lane to fix.
   Reverses: `git revert c080a44f`.

11. Reserve a federated address only when the provider verified it (round 1)
    Taken:    `InMemoryFederatedAccounts::resolve_or_create` filters the asserted `email` through `identity.email_verified` before it either compares it against the reserved set or inserts it. An unverified address is carried on the identity and otherwise ignored: it reserves nothing, collides with nothing. Same rule in the fixture double. Interim until #460 folds federated rows into the one account table, where the address is the verified one a password account registered with.
    Rejected: reserving every asserted address (registering somebody else's address at the provider, unverified, would hold its real owner out of ever signing in here - a targeted denial of service costing one sign-up); refusing an unverified address outright (a provider that omits the claim entirely would then have no usable sign-in at all).
    Reverses: drop the `.filter(|_| identity.email_verified)` in `auth/oidc/accounts.rs` and the `&& identity.email_verified` in `tests/support/mod.rs`.

12. A one-hour ceiling on the JWKS cache (round 1)
    Taken:    `jwks::MAX_AGE = 1h`. `KeyCache::current` refetches a set older than the ceiling; the one-minute `REFRESH_FLOOR` on evidence-driven refetches stands beside it. Past the ceiling a failed refresh returns the error rather than serving the stale set.
    Rejected: evidence-only refresh (the original shape) - a key the provider *revoked* never generates the evidence, because every token it signed still names a `kid` the cache holds, so a revoked key would keep verifying until the process restarted; a short timer (it would hammer the provider for a rotation that already announces itself).
    Reverses: delete `MAX_AGE` and the age test in `current`.

13. Docs name #460 for the durable gap, and the conformance claim in decision 4 is struck (round 1)
    Taken:    `authentication.md` gains a "Deviation, named rather than substituted (issue #460)" bullet stating that the cross-directory `409` and the null credential `authenticate` refuses are owed rather than shipped, and that the test fixture's double encodes the intended contract. The `tests/support/mod.rs` double says the same in its own docs.
    Rejected: leaving decision 4's phrase "a conformance row" standing - there is no `store::conformance` row for the null-credential invariant; only the integration fixture's `impl FederatedAccounts for InMemoryAccounts` has it, and a decision record that claims a gate it does not have is worse than one that names the gap. Decision 4's parenthetical "(AccountDirectory::authenticate must return Refused for it - a conformance row)" is **struck**: read it as "an invariant the Postgres adapter owes, encoded today only in the integration fixture's double".
    Reverses: n/a - #460 delivers the row, at which point the claim becomes true.

14. `OIDC_ALLOW_LOOPBACK_REDIRECT` defaults off (round 1)
    Taken:    `read_oidc` maps an absent variable to `false`. The loopback arm of `RedirectPolicy` is opt-in: it is the one knob that widens where the server will send a person back to, and a deployment with a CLI or desktop client turns it on deliberately. `capsule-server/compose.yaml`'s dex profile sets it; `authentication.md` and `.env.example` state the default. #461 (the CLI loopback and device grant) carries exactly one comment saying its flow needs the variable on and should tell the operator so.
    Rejected: defaulting on so an unconfigured CLI works (it widens the redirect surface of every deployment, including the ones with only a web client, to buy convenience for a client that does not exist yet).
    Reverses: `None => true` in `read_oidc`.

15. The OIDC door does not consult the password lockout - recorded, not changed (round 1)
    Taken:    A note on `routes::auth::open_session_for` and a bullet in `authentication.md`. Behaviour unchanged: the callback opens a session without reading the local lockout.
    Rejected: refusing a federated sign-in on a locked local account - the lockout counts failed *credential presentations* against the local directory and a federated sign-in presents none (the provider already authenticated the person), so honouring it would let anyone who can guess passwords at `/v1/auth/login` lock a person out of single sign-on too, turning a throttle on one door into a denial of service on the other.
    Reverses: read `AccountDirectory` for `Locked` in the callback before `open_session_for`.

16. The SDK drives both OIDC legs through the generated client (round 1)
    Taken:    `AuthClient::begin_oidc_login` / `complete_oidc_login` call `rest::Client::begin_oidc_login` / `complete_oidc_login` with `rest::types::Oidc{Authorize,Callback}Request` and match Spargen's `Begin/CompleteOidcLoginError` variants; the hand-written request construction and response deserialization is deleted. Only the mapping to `AuthError` is hand-written orchestration.
    Rejected: the hand-rolled `reqwest` calls the first round shipped - AGENTS.md is explicit that everything which parses or serializes is generated and only orchestration *over* generated calls is hand-written, with the resumable upload state machine (`S-D1`) as the whole of the exception.
    Reverses: `git revert 5e4d2e8a`.

17. Beginning a ceremony is bounded twice (round 1)
    Taken:    `begin_oidc_login` hits `budgets::OIDC_AUTHORIZE` (60 a minute, keyed `CounterKey::OidcAuthorize(redirect host)`, the `S-C32` limiter) before it generates anything or asks the provider, answering `429 error.auth.rate_limited` and failing closed on a limiter outage; `InMemoryOidcAuthorizations::begin` purges expired records first and then refuses past `PENDING_AUTHORIZATION_CEILING` (10 000) with `StoreError::Rejected`, which the route renders `503` on the retryable `error.auth.unavailable` code. Both responses are declared and produced in the conformance walk.
    Rejected: an unbounded store (an unauthenticated `POST` that writes a record is a memory-exhaustion primitive); keying the budget on the client IP (the server sits behind a proxy this change does not settle a header contract for) or on nothing (one deployment-wide bucket would let one noisy client deny everybody).
    Reverses: `git revert 91cf7a0c`.

18. `OIDC_CA_BUNDLE` for a private certificate authority (round 1)
    Taken:    An optional PEM path, read at boot into extra `reqwest::Certificate` roots for the relying party's client only, refused by name with a `BootError` when the file is missing or unparseable. The rest of the process keeps the platform roots.
    Rejected: an "insecure / skip verification" switch (a knob that turns TLS verification off is one that ends up on in production); adding the anchors process-wide (the identity provider's private CA is not a reason to trust it for every other outbound call).
    Reverses: `git revert d73f235c`.

Round-1 wire hardening (taken by the lane, inside the manifest; all in `273f42f9` unless noted).
    F3:  `discovery::is_loopback_issuer` demands `http` **and** an IP literal (`127.0.0.1`, `[::1]`, `::1`), and `admit` only tolerates a plain-HTTP endpoint when the *issuer* is itself loopback - there is no https carve-out for a public issuer, and `localhost` is admitted nowhere (RFC 8252 §8.3: a resolver can be made to send it elsewhere). `RedirectPolicy::admits` applies the same literal rule.
    F6:  `claims::bounded` cuts a provider-supplied string at `MAX_QUOTED_BYTES` (255) on a character boundary at construction, so `iss`, `kid` and the token endpoint's `error_description` cannot put a megabyte in a `WARN`.
    F8:  `#[serde(deny_unknown_fields)]` on `OidcCallbackRequest` (in `d294ad76`), so a client that forwards the provider's whole redirect query gets a `422` naming the field instead of a silent drop.
    F9:  a non-empty `crit` header is refused (RFC 7515 §4.1.11: it names extensions the recipient must understand or reject). An empty `crit` is malformed-but-harmless and is not what the rule is about.
    F10: `azp`, when present, must be this client, and a token whose `aud` names several parties must carry one (Core §3.1.3.7 rule 4).
    F11: the broken line continuation in the `OIDC_ISSUER` fault message is repaired, and `OIDC_REDIRECT_URL` is held to the issuer's scheme rule - https, or http on a loopback literal (in `d294ad76`).

19. Charge the OIDC authorize budget under a key the policy already admitted (round 2, finding F-A, High)
    Taken:    `IdentityProvider` gains `admits_redirect(&str) -> bool` - the pure, synchronous look-ahead over the same `RedirectPolicy` the adapter enforces with - and `begin_oidc_login` calls it before it charges anything. An admitted redirect keys `CounterKey::OidcAuthorize(host)` on `budgets::OIDC_AUTHORIZE` as before; every refusal keys the new unit variant `CounterKey::OidcAuthorizeRefused` on `budgets::OIDC_AUTHORIZE_REFUSED`, one deployment-wide bucket. Statuses are unchanged: an unconfigured deployment still answers 404 and a refused redirect still answers 400; only which bucket the refusal is counted in changed. The doc comments on the key and the budget now say the key space is three buckets *downstream of validation*, which is the only place the old claim was ever true.
    Rejected: keeping the budget first because "throttle before you work" (the work being avoided is one string comparison, and the ordering was buying nothing while handing an unauthenticated caller a lever on a process-wide map); a second copy of `RedirectPolicy` held beside the routes (two call sites that eventually disagree about what is admitted, invisible until one admits what the other refuses); `CounterKey::OidcAuthorize("<refused>")` as the fixed bucket (a sentinel string is exactly the hand-formatted key this enum exists to abolish); keying refusals by host with a tiny budget (still one map row per attacker-chosen host, which is the defect).
    Reverses: `git revert 36c73417`.
    Cost:     a flood of invalid redirects spends the one refusal bucket for every *other* invalid redirect. Deliberate and cheap: a client whose redirect the deployment admits never charges that bucket, so only misconfigured and abusive callers are affected, and the admitted path is proved unaffected by the flood in the pinning test.

20. Purge and bound the in-memory counter windows (round 2)
    Taken:    `InMemoryCounters` purges lapsed windows on every `hit` (from a per-window `purge_after` recorded off the budget in force when the window opened - a purge hint only; admission is still recomputed from `opened_at` against the budget the caller supplies, pinned by `purging_does_not_change_a_verdict`) and refuses a key it holds no window for past `WINDOW_CEILING` (100 000) with `StoreError::Rejected`. A key that already holds a window keeps counting to its own budget, so a flood of new keys cannot switch off a limiter that is already tracking somebody, and the ceiling is not a one-way door - once windows lapse, new keys fit again. The same two bounds `InMemoryOidcAuthorizations` carries.
    Rejected: a new `Verdict::AtCapacity` (every caller's rule for a counter failure is already "refuse", so a full store should deny through the path documented to fail closed rather than through a new one somebody could handle as an admission); a background sweeper (a task to own, a clock to inject, for a map that is already written on every hit); evicting the oldest window to make room (it would let an attacker evict the window that is throttling them, which is the limiter turning itself off under load).
    Reverses: `git revert d5b0afe5`.

21. `error.auth.oidc_at_capacity` splits the 503 from the 500 (round 2)
    Taken:    A new catalog key for the authorize's at-capacity refusal; `error.auth.unavailable` keeps the 500 alone. design/api-surfaces.md:173-174 - "REST status is coarse; the stable `error.*` code in the `ApiError` body is the precise discriminator. Clients switch on the code, never on status alone" - makes two conditions with different statuses sharing one code a contradiction rather than a shorthand: a client obeying that sentence literally could not tell them apart at all. The remedies differ too (a short retry timer against an outage to surface), which is what the catalog `context` now records. The wire test asserts both codes against both conditions.
    Rejected: keeping one code on the ground that both mean "wait and try again" - that was the round-1 reasoning and it does not survive the api-surfaces sentence, because a client that switches on the code alone would treat a five-second retry and a store outage identically; a generic `error.auth.at_capacity` shared with future ports (nothing else has the condition yet, and a code invented for callers that do not exist is a contract nobody validated).
    Reverses: `git revert 739845d9`, then `mise run i18n`.
    Note:     the SDK needs no change - `AuthClient::begin_oidc_login` already carries an unmapped code through `AuthError::Unexpected { code }`, so a client can switch on it. A typed `AuthError` variant is worth adding when a caller wants an affordance for it; nothing does yet.

Finding recorded, not fixed: the other caller-influenced counter keys (round 2, requested with decision 20)
    Every `CounterKey` variant that is actually charged was read. (Line numbers below were refreshed against `f1ab7d2a` and each is anchored to the symbol it names; three had drifted since round 2 - see decision 29.) Three are reachable unauthenticated with a caller-controlled string, and in all three the key is charged *before* the value is resolved - deliberately, and not reorderable the way decision 19's was:
      - `ShareLink` (`routes/share.rs:428`, `CounterKey::ShareLink(...)` inside `throttle`, called from `share_metadata`/`share_wrapped_secret`/`share_blob`). Unauthenticated, and the *worst* of the three: `throttle` runs before `live()`, which is where `is_opaque_id` lives, so the key is an arbitrary path segment of arbitrary length. Its own doc explains why - "charged before the link is resolved, so probing costs the prober whether or not the id exists; a limiter that only ran for real links would be a free oracle for the rest". Reordering it would trade a bounded map for an existence oracle on every share link, which is the worse bargain.
      - `DropLink` (`routes/drop.rs:645`, `CounterKey::DropLink(...)` in `create_drop`). Unauthenticated, but `is_opaque_id(&path.opaque_id)` runs first, immediately above it, so the key is shape-bounded to fixed-length lowercase hex. Shape-bounded is not cardinality-bounded: an attacker mints as many well-formed ids as they like.
      - `EnrollmentRedemption` (`routes/enroll.rs:310`, `CounterKey::EnrollmentRedemption(...)` in `redeem_enrollment_code`). Unauthenticated "necessarily" per the route's own docs - the code is the credential - and keyed on the presented code before redemption is attempted, on the same anti-oracle reasoning.
    Bounded, for the record: `SecondFactor` (`routes/totp.rs:557`, `CounterKey::SecondFactor(...)`) keys on a challenge id taken from an already-verified server-signed MFA token; `DeepVerify` (`routes/storage.rs:207`, `CounterKey::DeepVerify(...)`) keys on the authenticated session's user. `LoginAttempts`, `RegistrationSource`, `ShareSource` and `DropSource` are declared and charged nowhere - the password lockout lives in the account directory, and the three `*Source` keys are waiting on a trusted client address this server does not have.
    So: decision 20 is the remedy for all three, and is why it was worth doing as more than tidiness. Not fixed here because fixing them means either accepting the existence oracle or giving those routes a bounded-key scheme of their own (hashing the id into a fixed number of buckets, say), which is a design decision for the share and drop contracts and outside this lane's manifest. Named here so the next reader does not have to re-derive it.

Manifest note (round 2): `capsule-server/src/counter/{mod,budgets,tests}.rs` is not on the plan's `Touches:` line. `counter/{mod,budgets}.rs` was already widened into it by the orchestrator's decision-17 routing in round 1; `counter/tests.rs` and the ceiling work were directed by the orchestrator in round 2 with the finding, which is where the authority for decision 20 comes from. Recorded rather than assumed.

22. Partition the counter ceiling per `CounterKey` variant (round 3, Medium cross-surface finding)
    Taken:    `InMemoryCounters` holds `BTreeMap<&'static str, BTreeMap<CounterKey, Window>>`, partitioned by `CounterKey::as_str`, and each variant carries its own `CounterKey::ceiling` from the `counter::ceilings` module. `hit` purges, then compares **that partition's** occupancy against **that variant's** ceiling, and only for a key it holds no window for. `with_ceiling` now bounds every partition equally and is documented as the tuning/test affordance that makes the boundary observable without writing twenty thousand keys.
    Rejected: one `WINDOW_CEILING` for everything (the finding: `InMemoryCounters` is the only `CounterStore` and is instantiated once per process, three surfaces charge a caller-controlled key before resolving it, and the drop path's hour-long window made it the cheapest to hold saturated at roughly six fabricated ids a second - so a flood against the weakest surface refused a *first* key to every other, and every route maps a counter error fail-closed to a 500/503); raising the single ceiling instead (buys time, not independence - the flood is cheap enough to reach any number); a ceiling per *call site* rather than per variant (two call sites charging one variant would then disagree about its bound, which is the drift `CounterKey` exists as a closed enum to prevent).
    Reverses: `git revert c2a59e8a 04192ea0`.
    Sizing:   each ceiling is its variant's window length multiplied by a stated rate of *distinct* keys, rounded up. The budget bounds hits **per key** and says nothing about how many keys exist, so the key rate is the assumption and each one is written down beside its constant.
              - `LOGIN_ATTEMPTS` 10 000 — 15-min window; 900 s x ~1 account entering a failure window/s = 900, rounded up. Truly bounded by the directory size.
              - `ENROLLMENT_REDEMPTION` 5 000 — 10-min window; 600 s x ~1 code presented/s = 600, rounded up. Enrollment is a rare deliberate act.
              - `ENROLLMENT_REDEMPTION_MALFORMED` 1 — one key can exist.
              - `SHARE_LINK` 20 000 — 1-min window; 60 s x ~100 distinct links opened/s = 6 000, ~3x headroom because a public link is *meant* to be hit.
              - `DROP_LINK` 20 000 — 1-h window; 3 600 s x ~1 distinct link receiving a session/s = 3 600, ~5.5x headroom. Still the cheapest partition to saturate (under six ids/s) - which is the point: saturating it now costs the drop path its first-time keys and costs no other surface anything.
              - `SECOND_FACTOR` 10 000 — 5-min window; 300 s x ~10 sign-ins reaching a second factor/s = 3 000, rounded up. Not caller-controlled: the challenge id comes off a token this server signed.
              - `DEEP_VERIFY` 10 000 — 1-h window, authenticated account; bounded by the directory.
              - `SOURCE_ADDRESS` 10 000 (shared by `ShareSource`, `DropSource`, `RegistrationSource`) — charged nowhere yet; sized for distinct client addresses in the window on the day a trusted one exists.
              - `OIDC_AUTHORIZE` 16 — three keys can exist (the configured redirect's host and the two loopback literals). Sixteen rather than three so changing `OIDC_REDIRECT_URL` mid-window meets headroom instead of a cliff, and small enough to stay visibly bounded.
              - `OIDC_AUTHORIZE_REFUSED` 1 — one key can exist.
              Total **105 018** against the 100 000 it replaces: deliberately close, because the point is not to hold more windows, it is that the windows one surface holds are not the windows another is denied. (This line first read "115 017", which was an addition error; see decision 26. Ten constants, twelve variants - `SOURCE_ADDRESS` serves three of them.)

23. Shape-check the enrollment code before it can mint a counter key (round 3)
    Taken:    `routes::enroll::is_enrollment_code` admits exactly the two spellings `mint` issues - a canonical hyphenated UUID (36 chars, case-insensitive) or `TEXT_FALLBACK_DIGITS` (8) ASCII digits. A code of that shape keeps its own per-code `ENROLLMENT_REDEMPTION` budget; anything else is charged to `CounterKey::EnrollmentRedemptionMalformed` on `ENROLLMENT_REDEMPTION_MALFORMED` (60/min), one fixed bucket, exactly as decision 19 charges a refused redirect.
    Rejected: leaving the key unchecked (`request.code.trim()` was an arbitrary caller string of arbitrary length - the same lever decision 19 removed from the OIDC authorize, and the only one of the three caller-keyed surfaces with no shape gate at all); capping the length only (a length cap admits 10^8 eight-character strings *and* every 36-character string, so the partition is still attacker-filled - the exact shape is available here and is strictly better); refusing a malformed code early without charging (a free request, and it would change the response for a malformed code); rejecting upper-cased UUIDs (a client that upper-cases what it scanned is presenting a real code; throttling it deployment-wide on a technicality is a bug, and the store remains the authority on whether a code redeems).
    Reverses: `git revert 856e7313`.
    Note:     **a shape check is not an existence check, and must not be turned into one.** `is_enrollment_code` reads only the presented string, never the store, so it distinguishes "cannot possibly be a code" from "is shaped like a code" - never "exists" from "does not". The charge-before-resolve ordering that stops this route being a free existence oracle is untouched, and a malformed code still walks the same path to the same `error.enrollment.code_refused`. `the_shape_check_reads_no_store` pins that a minted code and one that was never issued are shaped identically.

24. Manifest widening for round 3 (recorded, per the orchestrator's decision 24)
    Shape:    **a bookkeeping decision, not a code change** - it records which files this lane was authorised to touch and which it raised. No commit names it, and none should: there is nothing to implement.
    Taken:    The orchestrator widened the manifest to `capsule-server/src/routes/enroll.rs` for decision 23, and nothing else in it. `capsule-server/src/counter/{mod,budgets,tests}.rs` were already widened in rounds 1 and 2. `capsule-server/tests/conformance.rs` was widened by round 1's decision 10. `capsule-docs/.../design/authentication.md` is on the original `Touches:` line.
    Raised:   **`capsule-server/tests/enroll.rs` is a widening the lane took and did not have.** Decision 23 forced it: three tests spent the *per-code* budget by grinding a placeholder string that is not shaped like a code (`"wrong-but-consistent"`, `"one-string"`, and the conformance walk's `"conformance-grind"`), so after the shape check they no longer reached a `429`. Two are in `tests/enroll.rs`; the third is in `tests/conformance.rs`, which is already in the manifest. The edit is minimal and strengthens what they assert - a well-formed guess is the only thing that can grind a pending enrollment, which is what the entropy argument for the short fallback was always about - plus one new test pinning that malformed attempts share one bucket and cannot deny a real redemption. Reported rather than assumed; the alternative was shipping `test-rust` red, which round 1's decision 10 already rejected.
    Rejected: widening silently; leaving three tests red for another lane; weakening `is_enrollment_code` so the placeholder strings pass (that is the defect).
    Reverses: n/a - the test repair is inside `856e7313`.

25. Ceiling exhaustion is a 429 with a code of its own, not a 500 (round 4, Medium)
    Taken:    `counter::is_at_capacity` recognises the partition-ceiling refusal, `counter::capacity_retry_after` bounds the wait at one window, and `CounterContext::capacity_refusal` puts the two together so three routes cannot answer differently. The drop, share and enrolment routes now render a full partition as `429` with `error.drop.at_capacity` / `error.share.at_capacity` / `error.enrollment.at_capacity` and a `retry_after`; only a store that could not answer at all is still a `500`. `InMemoryCounters::hit` refuses under the `COUNTER_STORE` constant `is_at_capacity` matches on, so the two cannot drift. The spent-budget `429` gains `retry_after` too, taken from the `Verdict::Limited` every route was discarding.
    Rejected: leaving all counter failures as `500` (the finding: `DropLink` is saturable at under six fabricated ids a second, and while it is held every *first-time* visitor to every drop link was told the server was broken - a client could not back off and an operator paged on `5xx` could not tell it from a real incident, the only signal being a server-side `WARN` nobody reads at 3am as "working as designed"); reusing the existing `*_rate_limited` code for it (that is the discriminator an operator separates the two causes with, and one code for "your budget is spent" and "the server is full" hides exactly the thing worth alerting on differently); a `Retry-After` **header** (the SDK already reads one, but Kynos's `Problem` has no header API, so it would mean going around the `ApiError` derive on three routes); a new `AtCapacity` variant beside `RateLimited` (same status, so one `EXTRAS` row would have to call `retry_after` nullable for a member both causes in fact set); folding it into the share path's indistinguishable `404` (a `404` that was really a capacity refusal teaches a legitimate viewer that a live link is dead).
    Reverses: `git revert 47ed85a2`.
    Note:     the refusal stays fail-closed either way - this changes only what the caller is told. The OIDC authorize's counter path was deliberately **not** changed: its two partitions are 16 and 1 keys wide against a policy that admits at most three redirect hosts, so exhaustion is not reachable by a caller, and a catalog key for an unreachable condition is the contract-nobody-validated that decision 21 already rejected.
    Widened: `capsule-server/src/openapi/mod.rs` gains **five** `EXTRAS` rows - one per throttled operation, because share's `throttle()` serves three separate OpenAPI operations - which that table's own docs name as the required step for any non-`code` extension member. Raised, not assumed. (This line first said "three rows (five operations)"; the change was always complete, the count was wrong. See decision 29.)

26. Correction: the ceiling arithmetic in this record was wrong (round 4)
    Taken:    The `ceilings` module declares **ten** `pub const` values, not eleven - `SOURCE_ADDRESS` is one constant shared by `ShareSource`, `DropSource` and `RegistrationSource`. Summed per **variant** (counting `SOURCE_ADDRESS` once for each of the three that use it) the total is **105 018**, not the 115 017 decision 22 claimed: 10 000 + 5 000 + 1 + 20 000 + 10 000 + 20 000 + 10 000 + 10 000 + 10 000 + 10 000 + 16 + 1. Against the single 100 000 it replaces that is a 5% rise, which makes the "deliberately close" claim *more* true than the wrong figure did.
    Rejected: quietly editing the number (the wrong figure is the one a human would have quoted back, so the correction says what was wrong and what the right count is); changing any shipped constant to make the old total right (every individual number is sound and every window matches its budget - it was the addition that was wrong, not the sizing).
    Reverses: n/a - a record correction; no code changed.

27. The single counter mutex is a recorded residual, not a fix (round 4)
    Taken:    All twelve partitions stay behind one `Mutex<Partitions>`. The module docs gain a "One lock, and what that does and does not cover" section saying that admission is genuinely independent while **latency** is not - a sustained flood against one key serialises `hit`/`peek`/`reset` for the others - and that the claim "the windows one surface holds are not the windows another is denied" is about admission and must not be read as a latency guarantee. Filed as issue #477 with the suggested shape.
    Rejected: per-partition locking in this PR (the critical section holds no `.await` and does `O(log n)` work over at most 20 000 entries, so at these sizes it is contention rather than denial; changing the locking of the port every limiter shares, in a review round, to fix something no test can currently exhibit, is how a correctness bug gets introduced to close a performance note); saying nothing (the module doc's own claim is what makes the gap worth naming).
    Reverses: n/a - documentation plus a filed issue.

28. Pin that only the counter store's own ceiling reads as a capacity refusal (round 4)
    Taken:    Two tests in `counter/tests.rs`: `is_at_capacity` is false for `StoreError::Unavailable` and for a `Rejected` naming any other store, and `CounterContext::capacity_refusal` - the method the three routes actually call - answers `None` for both while still offering the one-window deadline for the real ceiling.
    Rejected: relying on the construction argument alone. It is sound *today* - `InMemoryCounters::hit`'s only error path is the ceiling, so no route can misclassify - and it expires the moment a second `CounterStore` exists. `COUNTER_STORE` is `pub`, and a Valkey adapter (#460) refusing under that name for an unrelated reason would have a genuine outage rendered `429 error.*_at_capacity`: a caller told to retry a store that is down, an operator told nothing is wrong. That is a worse failure than the `500`-for-everything decision 25 replaced, and it would arrive in a lane that is not looking at this file.
    Reverses: `git revert f1ab7d2a`.
    Note:     dropping the `store` check from the match fails both tests, so they are guards rather than restatements.

29. Correction: three stale file:line citations in this record (round 4)
    Taken:    Decision 20's five citations were written in round 2 and were exact then. Three have since drifted, each by the number of lines a later commit of this lane inserted **above** them in the same file: `routes/share.rs:418` is now `:428` (decision 25 added ten lines of variant docs), `routes/drop.rs:634` is now `:645` (eleven), `routes/enroll.rs:288` is now `:310` (twenty-two, across decisions 23 and 25). `routes/totp.rs:557` and `routes/storage.rs:207` are untouched and still exact. Every citation is now given as `path:line` **plus the symbol it names**, so the next drift is recoverable by grep rather than fatal to the record. Decision 25's "three `EXTRAS` rows" is corrected to **five** - share's `throttle()` serves three separate OpenAPI operations, so the five throttled operations need five rows; the change was always functionally complete, the count was wrong.
    Rejected: renumbering silently (the same reason decision 26 gives: the wrong figure is what a human quotes back six months from now, so the correction has to say what was wrong); dropping line numbers entirely in favour of symbol names (a line number is what makes a citation checkable in one command, and a reviewer did check them - which is how these were caught).
    Reverses: n/a - a record correction; no code changed.
    Cause:    an append-only record cites a moving target. The citations were correct when written and the file kept growing above them; nothing re-reads the record against the tree when a later round edits the same file. The separate wrong numbers in the lane's round-4 *report* had a different cause - they were written from working memory of the diff rather than from a locator run against the final file, which is why the ones that came from actual `grep -n` output (`locales/en.json`, the route bodies) were exact and the ones for the file this lane had just grown by forty lines were not. The fix that generalises: never write a line number that did not come from a command in the same session, and anchor every citation to a symbol so drift degrades it rather than breaking it.

Unresolved review notes

(append-only)

  • Resolved by c080a44f (decision 10): the conformance walk now produces every declared response of the two OIDC operations.
  • For server: Valkey adapter for OidcAuthorizationStore and Postgres adapter for FederatedAccounts #460, recorded here so it is not lost: the development profile's federated accounts (auth::oidc::InMemoryFederatedAccounts) hold their own rows because capsule-server/src/auth/accounts_memory.rs was outside this lane's manifest. The recommended shape is impl FederatedAccounts for InMemoryAccounts with an Option<String> credential and a (issuer, subject) index, so that under serve --memory an identity whose address matches a password account is refused with 409 and an OIDC account has a profile row - the same shape the Postgres adapter takes over one account table.
  • Review the redirect allow-list default. OIDC_ALLOW_LOOPBACK_REDIRECT defaults to on so a CLI's ephemeral loopback listener works unconfigured (RFC 8252 §7.3); a deployment that wants only its web redirect sets it to false. The plan chose this default; it is worth a reviewer's eye because it is the one policy knob that widens what the server will redirect to.
  • Correction, 2026-09-09 (supersedes the note above). That note is now false and is left in place only as history. OIDC_ALLOW_LOOPBACK_REDIRECT defaults to off as shipped, since d294ad76 (decision 14): read_oidc maps an absent variable to false, RedirectPolicy admits only the exactly-configured OIDC_REDIRECT_URL unless a deployment turns the loopback arm on, and authentication.md, .env.example and the compose stack's dex profile all say so. The reason is the one the original note asked a reviewer to weigh: it is the single knob that widens where the server will send a person back to, so a deployment with only a web client should not get it unasked, and the CLI flow that needs it (auth: CLI OIDC login — loopback listener and device authorization grant (S-N2 remainder) #461, which carries one comment saying so) turns it on deliberately. No reviewer eye is owed on the default any more; what is still worth an eye is the policy shape — exact-string equality plus loopback IP literals on any port, localhost admitted nowhere.
  • For a re-reviewer, what round 1 changed and where to look: auth/oidc/jwks.rs (MAX_AGE, decision 12), auth/oidc/accounts.rs (email_verified, decision 11), auth/oidc/claims.rs (bounded, crit, azp — F6/F9/F10), auth/oidc/discovery.rs (is_loopback_issuer and admit — F3), routes/oidc.rs + store/memory.rs (budget, purge, ceiling — decision 17), config.rs + boot.rs (OIDC_ALLOW_LOOPBACK_REDIRECT, OIDC_CA_BUNDLE — decisions 14 and 18), capsule-sdk/src/auth.rs (generated client — decision 16).
  • mise run cli-surface-check is unavailable on this base. The task is not defined in this worktree's mise.toml and check-rust here does not aggregate it; it arrives with another lane. Nothing was substituted for it.
  • Round 2, 2026-09-09 — the High finding is fixed; three things a re-reviewer should still weigh. (1) The refusal bucket is deliberately blunt: one deployment-wide window for every refused redirect, so a flood of invalid redirects spends the refusal budget for everybody. Admitted clients are provably unaffected (the pinning test asserts it), but if a deployment ever wants refusals throttled more finely it needs a trusted client address, which is the same missing fact RegistrationSource waits on. (2) WINDOW_CEILING is 100 000 and untuned by any measurement — it is a backstop, chosen to be far above any honest rate, not a capacity plan. (3) ShareLink, DropLink and EnrollmentRedemption still key on a caller-supplied string charged before the value is resolved; the record above says why that ordering is right for them and why decision 20 is their remedy, but a reviewer who disagrees should say so, because the alternative is an existence oracle rather than a smaller map.
  • Round 3, 2026-09-09 — the cross-surface finding is fixed. What a re-reviewer should weigh, and one correction. Correction to the round-2 note above: it said "WINDOW_CEILING is 100 000 and untuned by any measurement — a backstop". That single constant is gone as of 04192ea0; there are now eleven, one per CounterKey variant, each with its window-length x key-rate arithmetic written beside it in counter::ceilings. The key-rate assumptions are still estimates rather than measurements, and that is the thing to weigh — if a deployment's real distinct-key rate exceeds one of them, the symptom is a fail-closed 500/503 for new keys on that one surface only, which is the whole gain over the shared ceiling. Also still true: (1) DropLink remains the cheapest partition to saturate (20 000 fabricated ids over a one-hour window, under six a second) — partitioning contains the blast radius to the drop path, it does not make that surface strong, and a per-source key is what would (waiting on the same trusted client address RegistrationSource waits on); (2) ShareLink still charges before is_opaque_id runs, unlike DropLink and now EnrollmentRedemption — deliberate per its own docs, but it means the share partition accepts keys of arbitrary length, and a reviewer who wants that tightened should say so, since a shape check there would be the same not-an-existence-check argument decision 23 makes.
  • Manifest: the lane widened into capsule-server/tests/enroll.rs and is telling you rather than assuming. Decision 23 forced it — three tests ground a string that is not shaped like a code while asserting the per-code budget. Two live in that file; the third is in tests/conformance.rs, already in the manifest since decision 10. See decision 24 for the full reasoning and what was rejected.
  • Round 4, 2026-09-09 — final round. One correction to the round-3 note, and what is left. Correction: the round-3 note above says "there are now eleven" ceiling constants. There are tenSOURCE_ADDRESS is one constant shared by ShareSource, DropSource and RegistrationSource — and the per-variant total is 105 018, not the 115 017 decision 22 originally claimed. The shipped numbers were always individually sound and every window matches its budget; the addition in the record was wrong. Decision 26 records it. What remains for a reviewer: (1) the key-rate assumptions behind every ceiling are estimates, not measurements — if a real deployment's distinct-key rate exceeds one, the symptom is now an honest 429 error.*_at_capacity on that one surface rather than a 500, which is what decision 25 bought; (2) DropLink is still the cheapest partition to saturate and partitioning contains the blast radius rather than removing it — a per-source key is the real fix and waits on a trusted client address; (3) ShareLink still charges before is_opaque_id runs, so its partition accepts keys of arbitrary length — deliberate per its own docs, but a shape check there would be the same not-an-existence-check argument decision 23 makes, and a reviewer who wants it should say so.
  • Deferred with an issue: perf(server): lock each counter partition separately (S-C32) #477, per-partition locking for InMemoryCounters (decision 27). All twelve partitions share one mutex. Admission is independent; latency is not. Not a practical denial at these sizes — no .await in the critical section, O(log n) over at most 20 000 entries — and deliberately not changed in a review round. The module docs say so and name the issue.
  • Round 4 follow-up, 2026-09-09 — lane closed. Two corrections, no open findings. (1) Decision 28 adds the regression guard for is_at_capacity's string match, which is safe by construction only while InMemoryCounters is the sole CounterStore; the Valkey adapter (server: Valkey adapter for OidcAuthorizationStore and Postgres adapter for FederatedAccounts #460) is the lane that would otherwise break it silently, and it will now fail a test instead. (2) Decision 29 corrects three file:line citations in decision 20 that drifted as later rounds grew the same files, and decision 25's EXTRAS row count (five, not three). Every citation in this record was re-checked against f1ab7d2a and is now anchored to the symbol it names as well as the line, so the next drift degrades a citation rather than breaking it. Nothing else in the record moved, and no shipped behaviour changed in either.

…rceptor pair

The design puts three request headers and three response headers on every
route; the server declared the request half on four upload operations and
never sent the response half at all. A Kynos ApiError has no response-header
seam, so routes/upload.rs rode X-Capsule-Protocol-Min/-Max as problem
extension members while capsule-sdk reads them from headers and got None.

The seam is on the interceptor. negotiation.rs adds two: Negotiation, mounted
router-wide outside the body-size limit, attaches the window to every
response the chain produces, errors and short-circuits included; ProtocolGate,
on a Group, reads the three request headers and refuses 426 or 400 before the
handler runs. Both read the one UploadPolicy window, which gains the advisory
min_client_build. The group holds the four upload session operations that
enforced the handshake per route until now; routes/upload.rs loses that
duplication, and the 426 keeps no window members in its body.

openapi::describe_negotiation_headers files the three response headers under
every response of every operation, since Kynos describes an interceptor's
headers on success responses only. The test fixture's client sends the
handshake on every request with raw() for its absence, and conformance.rs
gains a document census and a document-driven wire census that pin the gated
and exempt sets.

Refs #404
boot::assemble built the upload policy from UploadPolicy::default() regardless
of PROTOCOL_MIN and PROTOCOL_MAX, so a deployment that narrowed its window
published one range on /.well-known/capsule/server-info and enforced another
on POST /v1/upload. The policy is now built from the configured window, which
is also what the negotiation interceptors advertise and refuse against. The
new boot test reads both back through the surface.

Refs #404
Every gated operation requires X-Capsule-Protocol and refuses without it. The
shared reqwest client behind the generated REST client now carries that header
and X-Capsule-Crypto-Suite as defaults, from the build's own constants, so the
generated operations and the hand-written paths over the same transport send
them with no per-call argument. protocol_headers() is public so the SDK's other
transports can carry the same handshake from the same source.

Refs #404
The ProtocolGate group now holds every non-exempt operation; GET /v1/version,
the four /.well-known/capsule/* records, the three /s/{opaque_id}* reads and
the two /d/{opaque_id}* guest deposits stay on the router and carry only the
response headers. The census in tests/conformance.rs asserts the gated set is
exactly the complement of the pinned ten, the coverage walk produces the 426
and 400 every gated operation now declares, and one representative route per
module is refused before a credential or body is read. The boot tests that
register and sign in send the handshake, as every client does.

Refs #404
net::http_builder installs X-Capsule-Protocol and X-Capsule-Crypto-Suite as
default headers once; http_client builds it and dial_client adds its connect
timeout on top of the same builder. AuthClient::new, the sync consumer, the
generated client behind AuthenticatedClient and the CLI's version probe all
obtain their client there, so no SDK request reaches a gated route without
the handshake. The document now declares X-Capsule-Protocol required on every
gated operation, which puts the protocol date first in each generated
signature; the SDK's callers pass the build's own constant, the same value the
transport sends, and the sync consumer maps the 426 the feed can now answer.

Refs #404
reqwest::ClientBuilder is already must_use; clippy's double_must_use refuses
the second attribute under the repo's -D warnings.

Refs #404
The pending half of an OIDC authorization-code ceremony - the nonce the
ID token must echo, the PKCE verifier the token endpoint must see, and
the redirect URI that must be replayed verbatim - is a single-use,
short-window credential keyed by `state`. That is the shape the typed
ceremony stores exist for, so it becomes a fourth one beside the
revoke-all challenge, the enrollment code and the relay channel rather
than a field on the durable session store.

`OidcAuthorizationStore::consume` is destructive on every attempt, like
`ChallengeStore::consume`: a replayed `state` finds nothing, two
callbacks racing one `state` resolve to one winner, and the nonce can
never be checked twice. The TTL is the store's, fixed at ten minutes.

`OidcState`, `OidcNonce`, `PkceVerifier` and `AuthorizationCode` join
the secret identifier newtypes and redact themselves in `Debug`.

The in-memory adapter joins `InMemoryStores`, and two conformance rows
assert single use and expiry. The harness accessor for the new store is
optional with a default of `None` so a container-backed harness written
elsewhere keeps compiling until its adapter lands; `run_all` skips the
two rows for such a harness and the rows themselves refuse to run
against nothing.
… window

threat-model/validation.md scopes the 426 to a write and says reads of any
past version succeed. The gate is now two: ProtocolGate on the group of
non-safe operations refuses a grammatical protocol date outside the window
with 426, and ProtocolReadGate on the group of GET and HEAD operations admits
any grammatical date and refuses only a missing or malformed handshake with
400. Because an interceptor's declaration is its type, a read now declares the
400 alone and no 426 it never renders; the census asserts exactly that, the
coverage walk and the per-module table follow the split, the 413 loop and a
new 401 case assert the window headers on refusals the gates did not make,
and the feed's dead 426 arm leaves the SDK's sync consumer.

Refs #404
`auth::oidc::claims::verify_id_token` takes the token, the key set it
must verify under, what the relying party expects and the instant to
judge time against, and returns either the identity the token
establishes or the first check it failed. Nothing is fetched and no
clock is read, so a foreign key, a wrong audience, an expired token and
a replayed nonce are each a unit test against a key the test generated.

`jsonwebtoken` is asked for the signature only. Its temporal checks read
the system clock, which a test cannot move, and each claim check here -
exact `iss` equality, `aud` containing the client, `azp` when present,
`exp`/`nbf`/`iat` with a sixty-second skew, the nonce, a bounded `sub` -
is a security decision this repository wants legible.

The header's algorithm is allow-listed to RS256, ES256 and EdDSA before
any key is consulted, a symmetric key in the set cannot verify anything,
and a key published for one algorithm is not used under another. A
header without `kid` resolves only against a set of exactly one key.
`UnknownKey` is the one rejection a caller acts on: it is what asks the
key cache to refetch.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 5, 2026

Copy link
Copy Markdown

Deploying capsule with  Cloudflare Pages  Cloudflare Pages

Latest commit: f1ab7d2
Status: ✅  Deploy successful!
Preview URL: https://4438422b.capsule-22k.pages.dev
Branch Preview URL: https://feat-oidc-relying-party-407.capsule-22k.pages.dev

View logs

Clippy's pedantic set flags the iter().any() spelling; the meaning is
unchanged and the gate is what the workspace lint task runs.
PROTOCOL_MIN and PROTOCOL_MAX defaulted to the single day capsule-core speaks,
collapsing the window a fresh deployment accepts to one date, and neither was
checked for shape, so 2026-6-1 sorted before 2026-12-31 for the wrong reason.
Both now default to the policy's year window, parse as strict YYYY-MM-DD
dates, and may be equal. MIN_CLIENT_BUILD joins them, validated as
MAJOR.MINOR.PATCH and handed to the upload policy, so the advisory
X-Capsule-Min-Client-Build on every response is the operator's value rather
than the crate default. With both validated at the boundary, an unencodable
window value is a programming error and the response encoder says so.

Refs #404
Every transport constructor that accepts a reqwest::Client names
net::http_builder as its source; a client built any other way sends no
protocol handshake and every gated route refuses it.

Refs #404
Every gated route now refuses a request without X-Capsule-Protocol, and the
hand-written web auth client sent none, so sign-in, registration, refresh and
every authenticated call from the browser were refused. All five request
builders send the protocol version this client is written against, restated
from capsule_core::crypto::primitives::PROTOCOL_VERSION because the browser
holds no Rust and the wasm surface does not export it.

Refs #404
`auth::oidc::IdentityProvider` is the port the OIDC routes will drive:
two methods - the URL to send a person to, and an identity for the code
they come back with - because discovery is how both are answered rather
than an operation of its own. It is the feature's one external boundary,
so it is the one thing doubled; `Disabled` is the null object an
unconfigured deployment runs with, so the routes have one shape whether
or not `OIDC_ISSUER` is set.

`HttpIdentityProvider` composes discovery (cached a day, refused if the
document names another issuer or a plain-http endpoint off loopback), a
key cache refetched on an unknown `kid` and floored at one fetch a
minute, a form-encoded token exchange (HTTP Basic when a client secret is
configured, PKCE-only otherwise) and the pure claim checks. Redirect
URIs are client-supplied and allow-listed: the configured one exactly,
plus loopback IP literals on any port by default (RFC 8252).

`FederatedAccounts::resolve_or_create` maps `(issuer, subject)` to an
account in one operation; an asserted address another account holds is
`AddressTaken`, never a link. The in-memory adapter holds its own rows
and says so - #460 owes the Postgres adapter over one account table.

`Config` reads `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`,
`OIDC_REDIRECT_URL` and `OIDC_ALLOW_LOOPBACK_REDIRECT`, refusing half a
relying party either way round; `boot::assemble` builds the relying
party lazily so a provider that is down never stops a server booting,
and refuses `OIDC_ISSUER` under the durable backends by name until #460
lands.

`reqwest` is promoted to a workspace dependency and to `capsule-server`
(rustls-tls, json); it was already in the lock file through the SDK, so
this adds an edge and no crate. The test suite gains an in-process mock
provider on loopback - discovery JSON, a JWK Set, a form-decoding token
endpoint minting EdDSA ID tokens with a tamper per negative case - and
drives the real adapter against it: caching, rotation, the refetch
floor, a wrong verifier at the token endpoint, and every claim refusal.
api.ts is hand-written, so the header could silently go missing again. A
recording mock of the global fetch drives the five request builders and checks
X-Capsule-Protocol equals PROTOCOL_VERSION on each, and PROTOCOL_VERSION is
compared with the literal in capsule-core/src/crypto/primitives.rs read at
test time, so the restated constant cannot drift from its source of truth.

Refs #404
`POST /v1/auth/oidc/authorize` begins a ceremony - fresh state, nonce
and PKCE material, the client's redirect URI admitted by the policy -
and answers the provider's authorization URL, the state and the
ceremony's deadline. `POST /v1/auth/oidc/callback` burns the state
first and whatever happens next, redeems the code through the provider
adapter, resolves the verified identity to an account keyed on
`(issuer, subject)`, honours a confirmed second factor with the same
`202` the password path issues, and opens the session through the same
`open_session_for`. Both mount inside the `ProtocolGate` group, as
every other non-safe operation does.

Every ID-token refusal is one code on the wire,
`error.auth.oidc_token_invalid`, with the specific reason in the log,
so the callback is not an oracle over which checks the relying party
runs. A burned, expired or unknown state is one code. An asserted
address another account holds is `409 error.auth.oidc_address_taken`
and never a link. The callback declares no `404`: an unconfigured
deployment holds no pending ceremony, so it answers the state code.

`server-info` gains `auth.oidc: { authorize, callback } | null` -
endpoints only, never the issuer or client id - which is how a login
chooser decides whether to offer the path. Seven `error.auth.oidc_*`
keys join the catalog and the OpenAPI document is regenerated with the
two operations.

The route suite drives the fixture's provider double for every declared
response, and the real adapter against the mock provider for the whole
handshake and every tamper through the route.
`AuthClient::begin_oidc_login(redirect_uri)` posts the client's own
callback URI to `/v1/auth/oidc/authorize` and returns where to send the
person plus the `state` the provider's redirect will echo;
`complete_oidc_login(state, code)` posts the redirect's answer to
`/v1/auth/oidc/callback` and returns the same `LoginOutcome` a password
login does, so a second-factor challenge on a federated account reaches
the caller as a value rather than a malformed pair. The browser leg
between the two is the platform's - the CLI's loopback listener and the
device grant are #461.

The configured cohort hash rides the callback and not the authorize,
because the callback is the request that opens the session. The `200`
and `202` reading is shared with the password login rather than
duplicated.

`AuthError` gains the OIDC refusals, matched on the catalog code:
`OidcNotConfigured`, `OidcRedirectInvalid`, `OidcAddressTaken`, and one
`OidcRejected { code }` for the callback's three `401`s, every one of
which means "start again". Proven against the crate's in-process mock
server; the round trip against the real router lives in
`capsule-server/tests/oidc.rs`.
…t provider

`authentication.md` gains the section the design did not carry before
the relying party was written: the two-request ceremony, the
client-supplied and allow-listed redirect URI, the burned state, one
wire code for every ID-token refusal, lazy discovery with the issuer
mix-up defence, accounts keyed on `(issuer, subject)` and never linked
by address, the second factor honoured, `openid email` with no `profile`
scope, `auth.oidc` in `server-info`, the five variables, and the two
deviations named rather than substituted. `dependencies.md` widens the
HTTP-client row to the server's one egress and adds the OIDC row with
the priced rejection of `openidconnect`.

`capsule-server/compose.yaml` gains a dex service behind `--profile
oidc` with a public `capsule` client and inline configuration, so a
development server can be pointed at a real provider on loopback.

`SLICES.md`: `S-N1` is done with its deviations recorded; `S-N2` is
part - the SDK half landed, the CLI remainder is #461.
`every_declared_response_is_exercised` asserts, through one client,
that every response the document promises was produced; the two OIDC
operations added fourteen. Both paths join the body-size list, and an
`oidc_block` after the second factor's drives the rest through the
fixture's provider double and its switches: the extractor's 415/422 on
both, the authorize's 200/404/500 (provider)/500 (store), and the
callback's 401/500/200/409/202 - the 202 by enrolling a factor on the
federated account the walk just created. The 400 and 426 are the gate's
and already come from the document walk.

Additive, in its own block, so a sibling adding its own block to the
same walk merges mechanically.
Review round 1 on #459. An ID token with a non-empty `crit` header is
refused (RFC 7515 §4.1.11: extensions the verifier must understand, and
it implements none), and a token whose `aud` names several parties must
carry `azp` (OpenID Connect Core §3.1.3.7 rule 4). The provider-supplied
strings that reach a log line - the token's `iss` and `kid`, the token
endpoint's `error` and `error_description`, a refused endpoint URL -
are bounded at 255 bytes at construction.

Under the loopback-issuer carve-out every plain-http endpoint must
itself be loopback, so a provider on this machine cannot send the code
and verifier off-box in the clear; `localhost` is no longer loopback
here, for the reason the redirect policy refuses it (RFC 8252 §8.3).

The key cache gains a one-hour ceiling beside the evidence-driven
refetch and its floor: a key the provider revoked never produces the
unknown-kid evidence, so without a ceiling it would verify until the
next rotation. Past the ceiling a failed fetch is returned rather than
served stale.

The mock provider gains two wire tampers - a signature flipped under a
published kid, and a symmetric key published under a kid a token then
names - so those refusals are proven over the wire rather than only in
the validator's unit tests; its tokens live three hours so the ceiling
can be exercised against a clock the tests move.
Review round 1 on #459 (F1). `POST /v1/auth/oidc/authorize` is an
unauthenticated write into a store, so it is bounded twice. A counter
budget per redirect host - sixty a minute, `budgets::OIDC_AUTHORIZE`,
keyed on the one fact the request carries that an attacker cannot vary
freely, since the policy admits three hosts at most - answers `429
error.auth.rate_limited` before anything is generated or asked of the
provider. The in-memory ceremony store purges expired records on every
`begin`, as the other in-memory stores do, and refuses at a ceiling of
ten thousand pending ceremonies with `StoreError::Rejected`, which the
route renders as `503` under the existing retryable
`error.auth.unavailable` code: the remedy is the one that code already
tells a person. The Valkey adapter (#460) gets expiry from the TTL.

The conformance walk and the route suite produce both new responses;
the OpenAPI document is regenerated.
…ied it

Review round 1 on #459 (decision 11). An address a provider asserts
without `email_verified` is one anybody at that provider could have
typed; reserving it would let a person register somebody else's address
there, unverified, and hold the real owner out of signing in here. So an
unverified address neither reserves nor collides: it is carried on the
identity and otherwise ignored, which makes `email_verified` the claim's
one production reader. Interim until #460 folds federated rows into the
one account table, where the address is the verified one.

The fixture double applies the same rule and now says it encodes #460's
contract rather than shipped behaviour. `open_session_for` records why
the OIDC door does not consult the password lockout (decision 15): the
lockout counts failed credential presentations, a federated sign-in
presents none, and refusing there would let a password-guesser lock a
person out of single sign-on.
… to the scheme rule

Review round 1 on #459 (decision 14, F8, F11).
`OIDC_ALLOW_LOOPBACK_REDIRECT` now defaults to off: admitting a redirect
to any loopback port is what a CLI's listener needs and is the one knob
that widens where the server sends a person back to, so a deployment
with such a client turns it on (#461's CLI flow will). The dex comment
in the compose stack sets it.

`OIDC_REDIRECT_URL` is validated as the issuer is - https, or http on a
loopback literal - and a broken line continuation in the issuer fault's
message is repaired. The callback body refuses unknown fields, like the
authorize's, so a client that forwards the provider's whole redirect
query gets a 422 naming the field; the OpenAPI document is regenerated.
Review round 1 on #459 (decision 18, F7). `OIDC_CA_BUNDLE` names a PEM
bundle of additional trust anchors for reaching a provider behind a
private CA - the ordinary enterprise case. The path is configuration
and its contents are not: `Config` carries the path, `boot::assemble`
reads and parses it once, refusing by name (never by content) when it
is missing, not a certificate bundle, or empty, and hands the roots to
the relying party's HTTP client through `add_root_certificate` - added
to the public roots, never replacing them.

Proven over a real handshake: the mock provider gains a TLS variant
behind a private CA and a leaf it signed for `127.0.0.1`. Without the
bundle the relying party reports the provider unavailable before a
byte reaches it; with it the whole handshake round-trips. `rcgen`,
`rustls` and `tokio-rustls` join the server's dev-dependencies with the
SDK's exact versions and features, so nothing new enters the lock file.

The bad-signature tamper now flips the first signature character rather
than the last, whose padding bits made the flip sometimes malformed
instead of mis-signed.
Review round 1 on #459 (decision 16). The `auth.rs` exemption from the
generated client is for token orchestration - the pre-flight refresh,
the 401-retry-once replay, the session store - and the OIDC legs are
none of that. `begin_oidc_login` and `complete_oidc_login` now call the
spargen-generated `rest::Client` over the SDK's one transport; the three
hand-written body types are gone, and every body and every response,
the 202 challenge included, is parsed by generated code. What stays
hand-written is the mapping into `LoginOutcome` and `AuthError`, which
now shares one status-to-variant table with the password path.

The generated client is built on the server root, derived from the auth
base by removing its `/v1/auth` suffix. Its transport-class failures
are not `reqwest::Error`s, so they surface as a new `AuthError::Network`
naming the endpoint. The in-crate mock now serves the generated paths
and answers refusals as RFC 9457 problems carrying the code, which is
what the generated client parses.
…settings

Review round 1 on #459. `authentication.md` now states the loopback
redirect arm as opt-in, the two bounds on beginning a ceremony, the
loopback carve-out's endpoint rule, the key cache's one-hour ceiling
beside its floor, `OIDC_CA_BUNDLE`, the verified-address rule for the
409, the lockout the OIDC door does not consult, and - named rather
than substituted - the two account-table properties #460 owes rather
than this change ships. `.env.example` gains the single sign-on section
with all six settings, and `dependencies.md` records the server's TLS
dev-dependencies for the private-CA test.
Review round 2 on #459 (decision 19, finding F-A). The authorize computed
`CounterKey::OidcAuthorize(host)` from the caller-supplied `redirect_uri`
before the redirect policy had ever looked at it - the policy runs inside
`authorization_url()`, one step later. An unauthenticated caller looping
the route with a fresh host each time therefore got a 400 every time,
wrote nothing to the ceremony store, and still added one permanent row
per request to the counter store, which is process-wide and shared with
the login and second-factor limiters.

`IdentityProvider` gains `admits_redirect`, the pure look-ahead over the
same `RedirectPolicy` the adapter enforces with, so the route can pick
its key from the policy's verdict: an admitted redirect keys on its host
- three at most, which is what the module's doc comment always claimed -
and every refusal shares the new unit key `OidcAuthorizeRefused` on its
own deployment-wide budget. Refusals stay rate-limited; the key space is
the small constant it was documented to be. The doc comments on the key
and on the budget now say when the claim holds.

Pinned by a test that fires sixty distinct invalid hosts and asserts one
counter key afterwards, that the sixty-first refusal is a 429, and that
the admitted host is a separate bucket the flood cannot deny. Without
the reordering it observes sixty keys.
Review round 2 on #459 (decision 20). `InMemoryCounters` held every key
it had ever seen: a window whose budget had lapsed decided nothing but
kept its row, and no ceiling stopped the map. That is the facility every
limiter on the surface shares, and several of its keys are derived from
something a caller sent, so the growth was never one feature's problem.

The same two bounds `InMemoryOidcAuthorizations` carries. Lapsed windows
are purged on every `hit`, from a per-window purge hint recorded off the
budget in force when the window opened - a hint only, so admission is
still recomputed from `opened_at` against the budget the caller supplies
and re-tuning a budget behaves exactly as it did. Past `WINDOW_CEILING`
a key with no window yet is refused with `StoreError::Rejected`, while
every key that already holds one keeps counting: a flood of new keys
must not switch off a limiter that is already tracking somebody. Callers
already treat a counter error as a refusal, so a full store fails closed
through the path documented to do that rather than through a new one.

Decision 19 is what bounds the OIDC key at its call site; this is the
defence in depth beneath it, and the module doc says so rather than
leaving the ceiling to be read as a licence to key a counter on an
arbitrary string.
Review round 2 on #459 (decision 21). `error.auth.unavailable` carried
both the 503 the authorize answers when the pending-ceremony store is at
its ceiling and the 500 it answers when a store could not be reached at
all. design/api-surfaces.md is explicit that REST status is coarse, that
the stable error code is the precise discriminator, and that clients
switch on the code and never on status alone - so two conditions with
different statuses sharing one code leaves a conforming client unable to
tell them apart at all.

`error.auth.oidc_at_capacity` is the 503's own code. The remedies differ
as well as the conditions: at capacity is a short timer, because pending
ceremonies expire ten minutes after they start, while unavailable is an
outage to surface. The catalog entry says which is which, and the wire
test now asserts the two codes are the two conditions.
Review round 3 on #459 (decision 23). The redemption limiter keyed on
`request.code.trim()` with no shape check at all, where the share and
drop paths at least gate on `is_opaque_id` first. The presented value is
an arbitrary caller-supplied string of arbitrary length, so an
unauthenticated caller could fill the counter store a row at a time -
the same lever decision 19 removed from the OIDC authorize.

`is_enrollment_code` admits exactly the two spellings `mint` issues: a
canonical hyphenated UUID, or eight ASCII digits. A code of that shape
keeps its own per-code budget, which is what the entropy argument for
the short transcribable fallback rests on; anything else is charged to
`CounterKey::EnrollmentRedemptionMalformed`, one fixed bucket on a far
more generous budget, because a malformed string cannot match a pending
enrollment and so is not a guess at one.

A shape check is not an existence check and must not become one. It
reads only the presented string, never the store, so it cannot tell a
pending code from an absent one and tells a prober nothing. The
charge-before-resolve ordering that stops this route being a free
existence oracle is untouched, and a malformed code still walks the same
path to the same `error.enrollment.code_refused` it always did.

Three tests spent the *per-code* budget with placeholder strings that
are not shaped like codes - two in `tests/enroll.rs` and the conformance
walk's 429 producer. All three now grind a well-formed guess, which is
what the property was always about, and a new wire test pins that
malformed attempts share one bucket and cannot deny a real redemption.
`capsule-server/tests/enroll.rs` is a manifest widening the lane raises
rather than assumes - see the decision record.
Review round 3 on #459 (decision 22). The single `WINDOW_CEILING` bought
a memory bound at the cost of a shared fate. `InMemoryCounters` is the
only `CounterStore` and is instantiated once per process, and three
surfaces charge a caller-controlled key before resolving what it names -
the share path, the drop path and the enrollment redemption. The drop
path's window is an hour long, which makes it the cheapest to hold
saturated: roughly six fabricated but well-formed ids a second held the
whole map full. Saturated, `hit` refused a *new* key from *any* surface,
and every route maps a counter error fail-closed to a 500 or 503 - so a
first-time share view, a first enrollment redemption after a reboot and
the first OIDC sign-in of the day could all be denied by a flood against
the weakest of the four.

The store now holds one partition per `CounterKey` variant, each with
its own `CounterKey::ceiling` sized from that variant's window and its
own plausible rate of distinct keys; `counter::ceilings` carries the
arithmetic for every number. Filling one partition refuses new keys in
that partition only. The totals are close to the single ceiling they
replace, deliberately: the point is not to hold more windows, it is that
the windows one surface holds are not the windows another is denied.

`with_ceiling` now bounds every partition equally, which is what lets a
test observe the partition boundary without writing twenty thousand
keys. Pinned by a test that fills the drop path, then asserts a
never-seen share link, OIDC host, enrollment code and second-factor
challenge are all still admitted and that the flooded partition's own
keys keep counting. Under one shared ceiling that test observes the
share link denied.
…pe check

Review round 3 on #459 (decisions 22 and 23). The authentication design
said the counter store "holds a ceiling of its own", which after this
round is one ceiling per key kind rather than one for everything - and
the difference is the whole finding: a single shared ceiling let a flood
against the cheapest caller-keyed surface deny a first-time key to every
other, single sign-on included. The bullet now says that, and names the
enrollment redemption's shape check as the same reasoning the redirect
check here already carries: a shape check is not an existence check, so
it bounds the key without reopening the oracle the charge-before-resolve
ordering exists to prevent.
Review round 3 on #459 (decision 22). The cross-surface test bounds
every partition with the uniform `with_ceiling` override so the boundary
is legible at fifty keys rather than twenty thousand. That proves
partitions are independent, but it does not spend the number a
deployment actually runs with, so an edit that sized `DropLink` off
another variant's constant would pass it.

This one fills `DropLink` to `ceilings::DROP_LINK` itself, then asserts
the two keys the finding named - a never-seen share link and a
never-seen OIDC redirect host - are still admitted, and that the flooded
partition keeps counting the keys it already holds. Ten seconds against
a sixty-second slow threshold.
Review round 4 on #459 (decision 25). The drop, share and enrollment
routes mapped *any* `Err` from `CounterContext::hit` to their generic
`Unavailable` variant, so `StoreError::Rejected` - the partition ceiling
refusing a new key, by design - surfaced as a 500 alongside a genuinely
broken store. Decision 22 stopped a flood against one caller-keyed
surface reaching the others; it did not change what the flooded
surface's own new visitors are told, and `DropLink` is saturable at
under six fabricated ids a second. While it is held, every first-time
visitor to every drop link was told the server was broken, and neither
a client nor an operator paged on 5xx could tell that from a real
incident. The only signal was a server-side WARN.

Ceiling exhaustion now leaves through the 429 path with a code of its
own - `error.drop.at_capacity`, `error.share.at_capacity`,
`error.enrollment.at_capacity` - and a `retry_after` bounded by that
partition's own window, which is an upper bound because the earliest
live window lapses no later than one window after it opened. Only a
store that could not answer at all is still a 500. The refusal is
fail-closed either way; what changed is what the caller is told.

`counter::{is_at_capacity, capacity_retry_after}` and
`CounterContext::capacity_refusal` keep the classification in one place,
so three routes cannot answer differently, and `hit` now refuses under
the `COUNTER_STORE` constant `is_at_capacity` matches on rather than a
repeated literal. The spent-budget 429 also gains `retry_after`, taken
from the `Verdict::Limited` every route was discarding.

The module docs say the consequence out loud beside the ceiling
arithmetic: partitioning bounds the blast radius and does not make the
flooded surface well, and a per-source key is what would.

Three EXTRAS rows describe `retry_after` on the five throttled
operations, as that table requires for any non-`code` extension member.
Review round 4 on #459 (decision 27). The module's "one lock" section
records that admission is partitioned while latency is not; it now names
issue #477, so a reader who wants the residual tracked does not have to
wonder whether anybody filed it.
Review round 4 on #459 (decision 28). `is_at_capacity` separates "the
partition is full" from "the store is broken" by matching
`StoreError::Rejected { store: COUNTER_STORE, .. }`. That is safe by
construction today because `InMemoryCounters::hit`'s only error path is
the ceiling, so no route can misclassify - and it stops being safe by
construction the moment a second `CounterStore` exists. `COUNTER_STORE`
is public, and a Valkey adapter (#460) refusing under that name for an
unrelated reason would have a real outage rendered `429
error.*_at_capacity`: a caller told to retry a store that is down and an
operator told nothing is wrong, which is worse than the
500-for-everything decision 25 replaced.

Two tests, against the adapter that does not exist yet: the predicate is
false for `Unavailable` and for a `Rejected` from any other store, and
`capacity_refusal` - the method the routes actually call - answers
`None` for both while still offering the one-window deadline for the
ceiling. Dropping the store check from the match fails both.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant