[FEAT] OIDC relying party on the server and SDK login flow - #459
Open
justin13888 wants to merge 33 commits into
Open
[FEAT] OIDC relying party on the server and SDK login flow#459justin13888 wants to merge 33 commits into
justin13888 wants to merge 33 commits into
Conversation
…ix/protocol-headers-every-route-404
…ix/protocol-headers-every-route-404
…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
…eat/oidc-relying-party-407
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.
Deploying capsule with
|
| 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 |
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
Summary
Commit plan (Conventional Commits, each leaving the tree coherent):
b63cf556feat(server): add the OIDC authorization ceremony store—OidcState/OidcNonce/PkceVerifier/AuthorizationCodesecret ids, theOidcAuthorizationStoreport withPendingAuthorizationand a fixed ten-minute TTL, the in-memory adapter, two conformance rows (single-use consume, expiry with the store).302da2f0+671f83e6feat(server): verify OIDC ID tokens as a pure function—auth::oidc::claims::verify_id_token, signature byjsonwebtoken, every claim check Capsule's, judged against an injected instant; eighteen negative cases.c21188d9feat(server): add the OIDC identity-provider port and its HTTP adapter—IdentityProvider(two methods) withHttpIdentityProviderand theDisablednull object; discovery cached a day with the issuer mix-up defence; JWKS refetched on an unknownkid, floored at one a minute; form-encoded exchange (Basic when a secret is configured);RedirectPolicy;FederatedAccountswithInMemoryFederatedAccounts;OIDC_*config; boot wiring (lazy, durable refusal by name);tests/support/idp.rsmock provider; nine wire tests.c27e86b1feat(server): mount the OIDC authorize and callback routes—POST /v1/auth/oidc/{authorize,callback}insideProtocolGate; sevenerror.auth.oidc_*keys andmise run i18n;server-infoauth.oidc | null;openapi.jsonregenerated (+2 operations); twelve route tests over the double and the real adapter.2c41ec05feat(sdk): begin and complete an OIDC login—AuthClient::begin_oidc_login/complete_oidc_login,OidcAuthorization, typedAuthErrorrefusals on the catalog codes.73977f05docs(design): record the OIDC relying party and ship a dex development provider—authentication.mdsection,dependencies.mdrows, dex under--profile oidcincapsule-server/compose.yaml,SLICES.mdS-N1done*/ S-N2part.c080a44ftest(server): walk every declared response of the OIDC operations— both paths in the conformance walk's 413 list and an additiveoidc_blockproducing the fourteen declared responses (decision 10).Merges:
8afeb9ec(#435 at459e8af3),1d471635(#453 at7526395b),e977c096(#453 at892f7575),62376d81(#453 final at032b6af2).Validation
All run inside the worktree, in the foreground. Aggregate gates at head
c080a44f; the focused checks at the commit that introduced them.cargo nextest run -p capsule-server --lib store::cargo nextest run -p capsule-server --lib oidc::config:: boot::cargo nextest run -p capsule-server --test oidccargo nextest run -p capsule-sdk --lib auth::mise run i18n-checkmise run openapi-check-kynosmise run check-docs-truthmise run check-mdmise run check-docsmise run check-rustcargo nextest run -p capsule-server --test conformance(atc080a44f)mise run test-rust(atc080a44f)cargo nextest run --workspace1888 run, 1888 passed;-p capsule-core --features ffi729 passed;-p capsule-sdk --features ffi165 passedmise run test-rust(at73977f05, before the conformance block)every_declared_response_is_exercisedlisted the 14 declared responses of the two new operations its walk never producedcaused, resolved byc080a44fonce the orchestrator widened the manifest (decision 10)podman compose -f capsule-server/compose.yaml --profile oidc configconfigs.dex_config.contentis honouredcargo nextest listcounts after each test-touching commitoidc) → 21 (oidc); never droppedRisks and rollout
Every slice is dark unless
OIDC_ISSUERis set; backing out is unsetting one variable. Theopenapi.jsonand locale additions are additive. No persisted data changes for a deployment that never enables it.Decisions taken
Unresolved review notes
(append-only)
c080a44f(decision 10): the conformance walk now produces every declared response of the two OIDC operations.auth::oidc::InMemoryFederatedAccounts) hold their own rows becausecapsule-server/src/auth/accounts_memory.rswas outside this lane's manifest. The recommended shape isimpl FederatedAccounts for InMemoryAccountswith anOption<String>credential and a(issuer, subject)index, so that underserve --memoryan identity whose address matches a password account is refused with409and an OIDC account has a profile row - the same shape the Postgres adapter takes over one account table.OIDC_ALLOW_LOOPBACK_REDIRECTdefaults 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 tofalse. 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.