Spec: public-client support + replace hand-rolled protocol code with the x/oauth2 + go-oidc + hstern trio #31
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lab/authz-bridge#31
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem Statement
The bridge is confidential-client-only.
OauthClient.clientSecretRefis CRD-required,client_secretis always sent on the authorization-code and refresh grants, and there is no PKCE anywhere. A public IdP client (like the test clienthttpbin-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.clientSecretRefbecomes optional; its absence declares a Public client (noclient_secretsent on any grant). A Confidential client (secret present) behaves as before, plus PKCE.golang.org/x/oauth2(authorization URL, PKCE, authorization-code exchange, refresh),coreos/go-oidc/v3(id_token validation), andgithub.com/hstern/go-token-exchange(RFC 8693).gorilla/securecookieblobs, keyed by a single shared Cookie key (BYO, with a leader-generated create-if-absent fallback).StaticKeySet.User Stories
OauthClientwithout aclientSecretRef, so that I can protect an App behind a public IdP client.client_secretfor a public client, so that an IdP which rejects a secret accepts the grants.clientSecretRef, so that I don't carry a secret the Exchange Flow never uses.nonceagainst the value it sent, so that a forged or injected id_token is rejected.Authorization(and my subject token consumed), so that the backend sees the exchanged token and never my raw subject token.Implementation Decisions
OauthClient.clientSecretRefbecomes optional. Absence = a Public client; presence = a Confidential client. No new enum/field.clientSecretRef(a present ref must still be well-formed). A Public client'sSecretReadyis trivially true (there is no secret to watch); the per-App Secret watch applies only to Confidential clients. A Public client never enters theSecretNotReadydegradation.x/oauth2: builds the authorization URL (with PKCE S256code_challengeandnonce), performs the authorization-code exchange, and the refresh grant. A public client is an emptyClientSecret(noclient_secretsent); the token endpoint usesAuthStyleInParamsto avoid an empty-Basic probe.coreos/go-oidc/v3: validates the id_token via a verifier over aStaticKeySetparsed from the leader-resolved JWKS (ES256). Thenonceclaim 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_idsent via the library'sExtra, actor fields omitted, no client secret (Kanidm rejects a secret on this grant).StaticKeySet; there is no serving-path JWKS fetch.{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 rotatedrefreshTokenreplaces the old one.Testing Decisions
Check/ServeHTTPresponses (status, headers, cookies) and the Registry's built output — not internal wiring.Build(pure function, injectableResolve+SecretReady) — public-client handling: a client without a secretRef builds an Available entry withSecretReadytrue; a present-but-malformed ref still degrades; a Confidential client still watches its secret.Builder(leader, envtest) — cookie-key authoring: absent key → leader creates it; present (BYO) key → leader leaves it; idempotent across re-runs.client_id, no secret, no actor fields; the exchanged token is cached and injected).builder_test.go, the serve*_test.gosuite (OIDC check/refresh, callback, exchange, logout), andidp_test.go. The serve tests already stand up a fake Snapshot + anhttptesttoken endpoint + a fake JWKS; the new behavior is exercised through that same seam.Out of Scope
Checkservice, the fail-closed status model of ADR 0011, claim/header injection) — unchanged.TokenEndpointavailability 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.Further Notes
portablecluster, reusing thehttpbin-authfixture (IdP: Kanidm, public clienthttpbin-auth-test). The last ticket is the rip-and-replace re-run (delete old CRs, the oldauthzappsCRD, and theauthz-bridge-systemnamespace; redeploy; verify OIDC + Exchange end-to-end), which also closes the #30 gateway gate.client_secretis sent (and both use PKCE).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.