Skip to content

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

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

[FEAT] OIDC relying party on the server and SDK login flow#459
justin13888 wants to merge 33 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 and every gate is green at head c080a44f.

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).

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

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

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`.

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.

…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: e54cfb5
Status: ✅  Deploy successful!
Preview URL: https://8b6bc165.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.
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