Spec: public-client support + replace hand-rolled protocol code with the x/oauth2 + go-oidc + hstern trio #31

Closed
opened 2026-09-15 23:18:27 +00:00 by ginjiruu · 1 comment
Owner

Problem Statement

The bridge is confidential-client-only. OauthClient.clientSecretRef is CRD-required, client_secret is always sent on the authorization-code and refresh grants, and there is no PKCE anywhere. A public IdP client (like the test client httpbin-auth-test) therefore cannot sign in: the IdP rejects the sent secret and/or requires PKCE. Public-client support is a product requirement.

Compounding this, the OIDC/OAuth2/RFC 8693 protocol code is hand-rolled (authorization URL, code exchange, refresh grant, id_token validation, token exchange, and the session/state cookie encoding). The bridge's real value is the ext_authz proxy plus the kube configuration interface; the protocol layer is custom code that mature libraries already solve. The goal is to minimize custom-written code by delegating the protocol to libraries.

Solution

Add public-client support to the OIDC Flow and replace the hand-rolled protocol code with mature libraries:

  • OauthClient.clientSecretRef becomes optional; its absence declares a Public client (no client_secret sent on any grant). A Confidential client (secret present) behaves as before, plus PKCE.
  • The protocol code is replaced by a trio: golang.org/x/oauth2 (authorization URL, PKCE, authorization-code exchange, refresh), coreos/go-oidc/v3 (id_token validation), and github.com/hstern/go-token-exchange (RFC 8693).
  • The Session and State cookies become signed+encrypted gorilla/securecookie blobs, keyed by a single shared Cookie key (BYO, with a leader-generated create-if-absent fallback).
  • The OIDC Flow always uses PKCE (Kanidm requires it for both client types), and the id_token nonce is validated.
  • Discovery and JWKS stay leader-resolved (keep-last-good, ADR 0005); the JWKS is parsed into a go-oidc StaticKeySet.

User Stories

  1. As an app operator, I want to declare an OauthClient without a clientSecretRef, so that I can protect an App behind a public IdP client.
  2. As an app operator, I want the bridge to send no client_secret for a public client, so that an IdP which rejects a secret accepts the grants.
  3. As an app operator, I want the bridge to always use PKCE on the authorization-code flow, so that public clients (which require it) and confidential clients (which Kanidm requires it for) both work.
  4. As an app operator, I want an exchange-only App to omit clientSecretRef, so that I don't carry a secret the Exchange Flow never uses.
  5. As an app operator, I want a public client's App to be Available (not Degraded) without a secret, so that it serves normally.
  6. As an app operator, I want a confidential client to keep working as before (secret sent) with PKCE added, so that existing Apps are not broken.
  7. As an app operator, I want the bridge to validate the id_token's nonce against the value it sent, so that a forged or injected id_token is rejected.
  8. As an end user, I want to sign in through a public-client App, so that I can reach the protected backend.
  9. As an end user, I want my session to survive the 15-minute id_token lifetime via lazy refresh, so that I am not bounced to re-login.
  10. As an end user, I want my session cookie to be signed and encrypted, so that it cannot be tampered with or read in the clear.
  11. As an end user, I want sign-in to be protected against CSRF, so that an attacker cannot plant a session in my browser.
  12. As an end user, I want to be returned to the page I originally requested after signing in, so that my flow is not interrupted.
  13. As an end user, I want to log out locally (clearing my session) and, where enabled, via RP-initiated logout, so that I can end my session.
  14. As a machine caller, I want to exchange my service-account token via RFC 8693 with no client secret, so that my App can use machine credentials.
  15. As a machine caller, I want the exchanged token cached per-replica, so that I don't pay an IdP round-trip on every request.
  16. As a machine caller, I want the exchanged token injected as Authorization (and my subject token consumed), so that the backend sees the exchanged token and never my raw subject token.
  17. As a cluster manager, I want to provide my own cookie key Secret, so that I control its rotation.
  18. As a cluster manager, I want the bridge to generate a cookie key when I don't provide one, so that I don't have to manage it.
  19. As a cluster manager, I want rotating the cookie key to be a deliberate act (invalidating all sessions), so that I understand the blast radius.
  20. As a developer, I want the protocol layer to use mature libraries, so that there is less custom code to maintain.
  21. As a developer, I want discovery and JWKS to stay leader-resolved with keep-last-good, so that a transient IdP outage does not break token validation.
  22. As a developer, I want the id_token validated against the Snapshot's JWKS (no serving-path IdP I/O), so that the stateless load model is preserved.
  23. As an operator, I want a broken public-client config (e.g. unresolved token endpoint) to fail closed as 5xx, so that misconfiguration is visible and never mis-authorizes.
  24. As an operator, I want a no/invalid session to produce a 302 re-login, so that the user is guided back to sign in.
  25. As an end user on a confidential-client App, I want PKCE to be used even though my client has a secret, so that my sign-in is as strong as a public client's.

Implementation Decisions

  • CRD: OauthClient.clientSecretRef becomes optional. Absence = a Public client; presence = a Confidential client. No new enum/field.
  • Registry: the structural gate relaxes — a client is valid with or without a well-formed clientSecretRef (a present ref must still be well-formed). A Public client's SecretReady is trivially true (there is no secret to watch); the per-App Secret watch applies only to Confidential clients. A Public client never enters the SecretNotReady degradation.
  • Protocol trio:
    • x/oauth2: builds the authorization URL (with PKCE S256 code_challenge and nonce), performs the authorization-code exchange, and the refresh grant. A public client is an empty ClientSecret (no client_secret sent); the token endpoint uses AuthStyleInParams to avoid an empty-Basic probe.
    • coreos/go-oidc/v3: validates the id_token via a verifier over a StaticKeySet parsed from the leader-resolved JWKS (ES256). The nonce claim is validated manually against the State blob's nonce (go-oidc does not check nonce).
    • hstern/go-token-exchange: performs the RFC 8693 exchange — client_id sent via the library's Extra, actor fields omitted, no client secret (Kanidm rejects a secret on this grant).
  • Discovery + JWKS: unchanged — leader-resolved, keep-last-good (ADR 0005). The JWKS bytes in the Snapshot are parsed into public keys and wrapped in a go-oidc StaticKeySet; there is no serving-path JWKS fetch.
  • Cookies: the Session cookie is a securecookie blob {idToken, refreshToken}; the State cookie is a securecookie blob {appKey, nonce, returnTo, pkceVerifier}. Both use the same securecookie codec/key. The PKCE verifier lives in the State blob (same 302→callback lifetime). On a lazy refresh the rotated refreshToken replaces the old one.
  • Cookie key: a single bridge-level key (HMAC hashKey + AES blockKey) shared by all replicas. BYO: if a key Secret already exists the bridge uses it. Fallback: otherwise the leader generates a strong random key and writes it create-if-absent (the leader already has Secret-create RBAC). Rotation invalidates all existing sessions.
  • PKCE: always on for the OIDC Flow (both client types). The verifier is generated on the 302, held in the State blob, and presented at the code exchange.
  • ADRs: new ADR 0012 (securecookie cookies + Cookie key) and ADR 0013 (the trio); ADR 0005/0007/0010 get cross-reference/supersession notes; ADR 0008/0009/0011 are unchanged.

Testing Decisions

  • Good tests assert external behavior — the ext_authz Check/ServeHTTP responses (status, headers, cookies) and the Registry's built output — not internal wiring.
  • Seams (all existing; no new seams):
    1. Registry Build (pure function, injectable Resolve + SecretReady) — public-client handling: a client without a secretRef builds an Available entry with SecretReady true; a present-but-malformed ref still degrades; a Confidential client still watches its secret.
    2. Registry Builder (leader, envtest) — cookie-key authoring: absent key → leader creates it; present (BYO) key → leader leaves it; idempotent across re-runs.
    3. Serve handlers — OIDC Flow end-to-end (auth URL carries PKCE + nonce; code exchange presents the verifier and, for confidential clients, the secret; refresh re-issues the Session blob; id_token nonce mismatch rejects; the Session/State blobs are signed+encrypted and round-trip) and Exchange Flow (RFC 8693 sends client_id, no secret, no actor fields; the exchanged token is cached and injected).
  • Prior art: the registry builder_test.go, the serve *_test.go suite (OIDC check/refresh, callback, exchange, logout), and idp_test.go. The serve tests already stand up a fake Snapshot + an httptest token endpoint + a fake JWKS; the new behavior is exercised through that same seam.

Out of Scope

  • The ext_authz glue (the Check service, the fail-closed status model of ADR 0011, claim/header injection) — unchanged.
  • The Registry/Snapshot architecture (ADR 0005) — unchanged except that the leader also authors the cookie-key Secret (fallback).
  • The Exchange Flow's injection + per-replica token cache (ADR 0009) — unchanged.
  • New Flow kinds (LDAP, SAML) — ADR 0004's future work.
  • The issue #30 review follow-ups (shared token-endpoint helper, TokenEndpoint availability gate, token-cache TTL bound, callback edge cases, gateway-CRD decision) — separate work; the E2E run here doubles as the #30 gateway verification gate only.
  • Back-channel logout; device flow.

Further Notes

  • E2E target: the portable cluster, reusing the httpbin-auth fixture (IdP: Kanidm, public client httpbin-auth-test). The last ticket is the rip-and-replace re-run (delete old CRs, the old authzapps CRD, and the authz-bridge-system namespace; redeploy; verify OIDC + Exchange end-to-end), which also closes the #30 gateway gate.
  • Kanidm facts (verified): PKCE S256 is required for all clients (public: mandatory; confidential: default, disable is an explicit insecure opt-out); id_tokens are ES256; refresh tokens rotate at-most-once with reuse-detection that kills the session (the bridge already uses the rotated refresh token).
  • Public client = no secret, exactly; the "public/confidential" distinction only changes whether client_secret is sent (and both use PKCE).
## Problem Statement The bridge is confidential-client-only. `OauthClient.clientSecretRef` is CRD-required, `client_secret` is always sent on the authorization-code and refresh grants, and there is no PKCE anywhere. A **public** IdP client (like the test client `httpbin-auth-test`) therefore cannot sign in: the IdP rejects the sent secret and/or requires PKCE. Public-client support is a product requirement. Compounding this, the OIDC/OAuth2/RFC 8693 protocol code is hand-rolled (authorization URL, code exchange, refresh grant, id_token validation, token exchange, and the session/state cookie encoding). The bridge's real value is the ext_authz proxy plus the kube configuration interface; the protocol layer is custom code that mature libraries already solve. The goal is to **minimize custom-written code** by delegating the protocol to libraries. ## Solution Add public-client support to the OIDC Flow and replace the hand-rolled protocol code with mature libraries: - `OauthClient.clientSecretRef` becomes **optional**; its absence declares a **Public client** (no `client_secret` sent on any grant). A Confidential client (secret present) behaves as before, plus PKCE. - The protocol code is replaced by a **trio**: `golang.org/x/oauth2` (authorization URL, PKCE, authorization-code exchange, refresh), `coreos/go-oidc/v3` (id_token validation), and `github.com/hstern/go-token-exchange` (RFC 8693). - The **Session** and **State** cookies become signed+encrypted `gorilla/securecookie` blobs, keyed by a single shared **Cookie key** (BYO, with a leader-generated create-if-absent fallback). - The OIDC Flow **always uses PKCE** (Kanidm requires it for both client types), and the id_token **nonce** is validated. - Discovery and JWKS **stay leader-resolved** (keep-last-good, ADR 0005); the JWKS is parsed into a go-oidc `StaticKeySet`. ## User Stories 1. As an app operator, I want to declare an `OauthClient` without a `clientSecretRef`, so that I can protect an App behind a public IdP client. 2. As an app operator, I want the bridge to send no `client_secret` for a public client, so that an IdP which rejects a secret accepts the grants. 3. As an app operator, I want the bridge to always use PKCE on the authorization-code flow, so that public clients (which require it) and confidential clients (which Kanidm requires it for) both work. 4. As an app operator, I want an exchange-only App to omit `clientSecretRef`, so that I don't carry a secret the Exchange Flow never uses. 5. As an app operator, I want a public client's App to be Available (not Degraded) without a secret, so that it serves normally. 6. As an app operator, I want a confidential client to keep working as before (secret sent) with PKCE added, so that existing Apps are not broken. 7. As an app operator, I want the bridge to validate the id_token's `nonce` against the value it sent, so that a forged or injected id_token is rejected. 8. As an end user, I want to sign in through a public-client App, so that I can reach the protected backend. 9. As an end user, I want my session to survive the 15-minute id_token lifetime via lazy refresh, so that I am not bounced to re-login. 10. As an end user, I want my session cookie to be signed and encrypted, so that it cannot be tampered with or read in the clear. 11. As an end user, I want sign-in to be protected against CSRF, so that an attacker cannot plant a session in my browser. 12. As an end user, I want to be returned to the page I originally requested after signing in, so that my flow is not interrupted. 13. As an end user, I want to log out locally (clearing my session) and, where enabled, via RP-initiated logout, so that I can end my session. 14. As a machine caller, I want to exchange my service-account token via RFC 8693 with no client secret, so that my App can use machine credentials. 15. As a machine caller, I want the exchanged token cached per-replica, so that I don't pay an IdP round-trip on every request. 16. As a machine caller, I want the exchanged token injected as `Authorization` (and my subject token consumed), so that the backend sees the exchanged token and never my raw subject token. 17. As a cluster manager, I want to provide my own cookie key Secret, so that I control its rotation. 18. As a cluster manager, I want the bridge to generate a cookie key when I don't provide one, so that I don't have to manage it. 19. As a cluster manager, I want rotating the cookie key to be a deliberate act (invalidating all sessions), so that I understand the blast radius. 20. As a developer, I want the protocol layer to use mature libraries, so that there is less custom code to maintain. 21. As a developer, I want discovery and JWKS to stay leader-resolved with keep-last-good, so that a transient IdP outage does not break token validation. 22. As a developer, I want the id_token validated against the Snapshot's JWKS (no serving-path IdP I/O), so that the stateless load model is preserved. 23. As an operator, I want a broken public-client config (e.g. unresolved token endpoint) to fail closed as 5xx, so that misconfiguration is visible and never mis-authorizes. 24. As an operator, I want a no/invalid session to produce a 302 re-login, so that the user is guided back to sign in. 25. As an end user on a confidential-client App, I want PKCE to be used even though my client has a secret, so that my sign-in is as strong as a public client's. ## Implementation Decisions - **CRD:** `OauthClient.clientSecretRef` becomes optional. Absence = a Public client; presence = a Confidential client. No new enum/field. - **Registry:** the structural gate relaxes — a client is valid with or without a well-formed `clientSecretRef` (a present ref must still be well-formed). A Public client's `SecretReady` is trivially true (there is no secret to watch); the per-App Secret watch applies only to Confidential clients. A Public client never enters the `SecretNotReady` degradation. - **Protocol trio:** - `x/oauth2`: builds the authorization URL (with PKCE S256 `code_challenge` and `nonce`), performs the authorization-code exchange, and the refresh grant. A public client is an empty `ClientSecret` (no `client_secret` sent); the token endpoint uses `AuthStyleInParams` to avoid an empty-Basic probe. - `coreos/go-oidc/v3`: validates the id_token via a verifier over a `StaticKeySet` parsed from the leader-resolved JWKS (ES256). The `nonce` claim is validated **manually** against the State blob's nonce (go-oidc does not check nonce). - `hstern/go-token-exchange`: performs the RFC 8693 exchange — `client_id` sent via the library's `Extra`, actor fields omitted, no client secret (Kanidm rejects a secret on this grant). - **Discovery + JWKS:** unchanged — leader-resolved, keep-last-good (ADR 0005). The JWKS bytes in the Snapshot are parsed into public keys and wrapped in a go-oidc `StaticKeySet`; there is no serving-path JWKS fetch. - **Cookies:** the Session cookie is a securecookie blob `{idToken, refreshToken}`; the State cookie is a securecookie blob `{appKey, nonce, returnTo, pkceVerifier}`. Both use the same securecookie codec/key. The PKCE verifier lives in the State blob (same 302→callback lifetime). On a lazy refresh the rotated `refreshToken` replaces the old one. - **Cookie key:** a single bridge-level key (HMAC hashKey + AES blockKey) shared by all replicas. **BYO**: if a key Secret already exists the bridge uses it. **Fallback**: otherwise the leader generates a strong random key and writes it create-if-absent (the leader already has Secret-create RBAC). Rotation invalidates all existing sessions. - **PKCE:** always on for the OIDC Flow (both client types). The verifier is generated on the 302, held in the State blob, and presented at the code exchange. - **ADRs:** new ADR 0012 (securecookie cookies + Cookie key) and ADR 0013 (the trio); ADR 0005/0007/0010 get cross-reference/supersession notes; ADR 0008/0009/0011 are unchanged. ## Testing Decisions - Good tests assert **external behavior** — the ext_authz `Check`/`ServeHTTP` responses (status, headers, cookies) and the Registry's built output — not internal wiring. - **Seams (all existing; no new seams):** 1. **Registry `Build`** (pure function, injectable `Resolve` + `SecretReady`) — public-client handling: a client without a secretRef builds an Available entry with `SecretReady` true; a present-but-malformed ref still degrades; a Confidential client still watches its secret. 2. **Registry `Builder`** (leader, envtest) — cookie-key authoring: absent key → leader creates it; present (BYO) key → leader leaves it; idempotent across re-runs. 3. **Serve handlers** — OIDC Flow end-to-end (auth URL carries PKCE + nonce; code exchange presents the verifier and, for confidential clients, the secret; refresh re-issues the Session blob; id_token nonce mismatch rejects; the Session/State blobs are signed+encrypted and round-trip) and Exchange Flow (RFC 8693 sends `client_id`, no secret, no actor fields; the exchanged token is cached and injected). - **Prior art:** the registry `builder_test.go`, the serve `*_test.go` suite (OIDC check/refresh, callback, exchange, logout), and `idp_test.go`. The serve tests already stand up a fake Snapshot + an `httptest` token endpoint + a fake JWKS; the new behavior is exercised through that same seam. ## Out of Scope - The ext_authz glue (the `Check` service, the fail-closed status model of ADR 0011, claim/header injection) — unchanged. - The Registry/Snapshot architecture (ADR 0005) — unchanged except that the leader also authors the cookie-key Secret (fallback). - The Exchange Flow's injection + per-replica token cache (ADR 0009) — unchanged. - New Flow kinds (LDAP, SAML) — ADR 0004's future work. - The issue #30 review follow-ups (shared token-endpoint helper, `TokenEndpoint` availability gate, token-cache TTL bound, callback edge cases, gateway-CRD decision) — separate work; the E2E run here doubles as the #30 **gateway verification gate** only. - Back-channel logout; device flow. ## Further Notes - **E2E target:** the `portable` cluster, reusing the `httpbin-auth` fixture (IdP: Kanidm, public client `httpbin-auth-test`). The **last** ticket is the rip-and-replace re-run (delete old CRs, the old `authzapps` CRD, and the `authz-bridge-system` namespace; redeploy; verify OIDC + Exchange end-to-end), which also closes the #30 gateway gate. - **Kanidm facts (verified):** PKCE S256 is required for all clients (public: mandatory; confidential: default, disable is an explicit insecure opt-out); id_tokens are ES256; refresh tokens rotate at-most-once with reuse-detection that kills the session (the bridge already uses the rotated refresh token). - **Public client = no secret**, exactly; the "public/confidential" distinction only changes whether `client_secret` is sent (and both use PKCE).
Author
Owner

All six tickets (#32-#37) are implemented and merged to master (PRs #38-#43): public-client config, securecookie Session/State + leader-authored Cookie key, OIDC Flow on x/oauth2 + go-oidc (StaticKeySet/ES256), always-PKCE + id_token nonce, Exchange Flow on hstern/go-token-exchange, and public-client E2E fixtures. The flaky snapshot suite (metrics-server :8080 collision under parallel load) is fixed in 8bd0b8a. Closing the spec.

All six tickets (#32-#37) are implemented and merged to master (PRs #38-#43): public-client config, securecookie Session/State + leader-authored Cookie key, OIDC Flow on x/oauth2 + go-oidc (StaticKeySet/ES256), always-PKCE + id_token nonce, Exchange Flow on hstern/go-token-exchange, and public-client E2E fixtures. The flaky snapshot suite (metrics-server :8080 collision under parallel load) is fixed in 8bd0b8a. Closing the spec.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lab/authz-bridge#31
No description provided.