Spec: The flow-serving bridge (gRPC ext_authz Check + OIDC/Exchange flows + callback/logout listener) #11
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lab/authz-bridge#11
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
An App team wants to protect its backends (API servers, web servers, dashboards) behind any Gateway API implementation that supports the GEP-1494
ExternalAuthfilter, using per-App authentication and no gateway-specific config. The state model is done: the leader resolves the App team's CRDs into the Registry, every replica mirrors it into a consistent, resolved, ready-to-use Snapshot, and App resolution is by hostname.But the Bridge does not yet serve anything. There is no gRPC
ext_authzserver that answers the gateway'sCheckcallouts, no OIDC sign-in (redirect to the IdP → callback → browser Session → per-request token validation → identity injection), no Exchange (RFC 8693) machine-token flow, no callback/logout listener, and no Services /ReferenceGrant. The App team can declare all the config, yet the Bridge does nothing with it. The user wants the Bridge to actually perform the protocol the state model provides the config for, so their backends are protected end-to-end.Solution
The Bridge's flow-serving layer: a gRPC
ext_authzserver that consumes the Snapshot and serves the flows, plus a plain-HTTP callback/logout listener, plus the Services and a controller-managedReferenceGrant.:8083gRPCext_authzOIDC,:8084gRPCext_authzExchange,:8082plain HTTP (callback/logout), exposed as three Services inauthz-bridge-systemplus a controller-managedReferenceGrant.id_token+refresh_token); per-requestid_tokenvalidation against the JWKS; claim→header injection; a lazy refresh on theCheckpath; a302to the IdP viadenied_response.audience= the App's own clientID), the exchanged token injected asAuthorization, a per-replica token cache.state/nonce cookie (set on the redirect) resolves the App and defeats CSRF; thecodeis exchanged for tokens; the Session cookie is set; logout clears it (or is RP-initiated).secretReady=false) / 302 (re-login) / 401 (no subject token); a failed exchange never mis-authorizes.end_session_endpoint, carried in the Snapshot, soEndSession(RP-initiated logout) works.User Stories
ext_authzCheckcallout for my App's OIDC-protected requests, so that my backend is gated behind sign-in.state/nonce cookie on the redirect (via thedenied_response), so that the callback can resolve my App and defeat CSRF.id_tokenagainst the JWKS on everyCheck(signature +exp+iss+aud), so that my sign-in is verified on each request.id_token's claims as headers on the request forwarded to my backend (per theOidcInjectionclaim→header mapping), so that my backend sees the identity.id_tokento be transparently renewed via therefresh_token(a lazy refresh on theCheckpath), so that I'm not bounced to Kanidm every 15 minutes.response_headers_to_add), so that the renewed tokens persist in the browser.Checkfor a request whose Host matches no App to be denied with 403, so that an unknown App is a "forbidden," not a mis-authorization.Checkfor an App whosesecretReady=falseto be served as 5xx, so that a broken config is "unavailable," not "forbidden."OidcSession.TTL, so that I understand how long a sign-in lasts.Authorization/Bearer), so that I control where the machine token comes from.audience= the App's own clientID; the policy's scopes), so that I can access the backend as my App.access_tokenasAuthorization: Bearer <token>and consume (headers_to_remove) the incoming Service Account Token, so that my backend sees the exchanged token, not the subject token.Checkwith a missing/malformed subject token to be denied with 401, so that a caller that presented no valid subject token is told to.audience) to be implicitly my Client's own clientID, so that machine tokens land in my App's own audience with no extra config.callbackURLto be handled by the Bridge's:8082listener, so that my sign-in completes.stateparam against thestatecookie (CSRF) before exchanging thecode, so that a forged callback is rejected.statecookie (appKey), so that the callback works without relying on the gateway forwarding the Host to the HTTP backend.codefor tokens (theauthorization_codegrant, using the client secret) and set the Session cookie (id_token+refresh_token), so that my user is signed in.returnTo), so that I'm returned to my original request.returnToto live in the plainstatecookie (not thestateURL param), so that the return URL isn't logged by Kanidm or leaked in the redirect.EndSession=true) to 302 the browser to Kanidm'send_session_endpointwithid_token_hint, so that I can end the session at the IdP too.end_session_endpointto be resolved from discovery and carried in the resolved state, so that RP-initiated logout works without a per-request discovery fetch.envoyproxy/go-control-planefor theext_authzCheckproto, so that I use the canonical, maintained contract.go-josefor JWKS + ES256 verification + claim checks, so that token validation is correct and not hand-rolled.:8082/:8083/:8084) inauthz-bridge-systemto be static config (part of the install bundle), so that I deploy them withkubectl.ReferenceGrantto be controller-managed (watching Routes, granting each Route's namespace permission to reference the bridge's Services, updated as Routes change), so that cross-namespacebackendRefsare authorized without hand-wiring.denied_response302 +Location+Set-Cookie, andresponse_headers_to_addSet-Cookie) to be a verified gate, so that I know a gateway that doesn't honor it simply can't serve OIDC (while Exchange still works).end_session_endpointand carry it in the Snapshot, so that the OIDC section is complete for RP-initiated logout.Implementation Decisions
Build surface
envoyproxy/go-control-planefor theext_authzCheckproto (the canonical home ofenvoy/service/auth/v3;grpc-gois already a transitive dep). Addgithub.com/go-jose/go-jose/v4for JWKS + ES256 signature verification + claim checks (exp/iss/aud).go 1.26.0. No hand-rolled crypto, no minimal proto vendor.Process and endpoint wiring
ext_authzCheckservice is registered on:8083(OIDC) and:8084(Exchange); the plain-HTTP callback/logout listener is on:8082. App resolution is by hostname viaAppForHostnamefor the gRPCCheck; the callback resolves the App from thestatecookie'sappKey.OIDC Flow (
:8083) — ADR 0007, ADR 0008id_tokenand therefresh_token; no server-side session store.id_tokenagainst the Snapshot'sJWKS(go-jose: match the key bykid, verify the ES256 signature, checkexp/iss/aud); inject theid_token's claims as headers per theOidcInjectionclaim→headermapping (OkHttpResponse.headers); returnOK.302to theauthorization_endpointviadenied_response(status=302+Location), and set thestate/nonce cookie on the samedenied_response(Set-Cookie).Checkpath: whenid_token.exp − now < margin(≈60s), use therefresh_tokengrant (client secret) to renew, re-issue the Session cookie viaOkHttpResponse.response_headers_to_add(Set-Cookie), then validate/inject the newid_token. On refresh failure, invalidate the Session and302(fail-closed).OidcSession.TTLcookie Max-Age; revocation is floored at 15 min by Kanidm's refresh check-in.Exchange Flow (
:8084) — ADR 0009Authorization/Bearer/access_token). Missing/malformed →401.audience= the App's own clientID; scopes from theTokenExchangePolicy). Inject the exchangedaccess_tokenasAuthorization: Bearer <token>;headers_to_removethe incomingAuthorization(consume the subject token). RFC 8693 rejected by Kanidm, or the token endpoint unreachable →5xx(fail-closed).(subject_token, scopes, audience); a miss/eviction just re-exchanges (no correctness impact).Callback (
:8082) — ADR 0010state/nonce cookie (appKey|nonce|returnTo) is set on theCheck's302; the callback validatesstateparam ==nonce(CSRF) and resolves the App fromappKey. It exchanges thecode(theauthorization_codegrant, using the client secret) → sets the plain Session cookie (id_token+refresh_token) →302toreturnTo.returnTolives in the plain cookie, not thestateURL param, so the return URL is not logged by the IdP or leaked in the redirect.Logout (
:8082)Set-CookiewithMax-Age=0).EndSession=true(opt-in) →302to theend_session_endpointwithid_token_hint(RP-initiated).EndSession=false(default) → clear +302back (local-only, zero-config).Fail-closed status model — ADR 0011
403secretReady=false5xx(503)302(to the authorization endpoint)OK+ injected claim headers4015xx(502/503)Manifests
:8082/:8083/:8084) inauthz-bridge-systemare static config (part of the install bundle).ReferenceGrantis controller-managed: a new reconciler watchesHTTPRoutes (andGRPCRoutes) across namespaces and owns aReferenceGrantgranting each Route's namespace permission to reference the Bridge's Services inauthz-bridge-system, updated as Routes appear/disappear.State-model amendment (follow-on to spec #1)
end_session_endpoint; theOIDCsection of the RegistryEntryand the Snapshot carries it. This is a small additive change (discovery is already fetched; extract one more field), not a redesign.Token-endpoint grants the Bridge performs
authorization_code(callback) — uses the client secret.refresh_token(keep-alive) — uses the client secret.token-exchange/ RFC 8693 (Exchange) — rejects the client secret. All hit the resolvedtoken_endpoint(every replica needs IdP token-endpoint egress; the leader still owns discovery + JWKS).Gateway verification gate
denied_response(302+Location+Set-Cookie) andresponse_headers_to_add(Set-Cookie). Verify against the lab gateway early. If unmet, the OIDC Flow does not work on that gateway; the Exchange Flow still does (it needs no302).cookiemust be listed in the OIDC rule'sallowedHeaders(ADR 0003).Testing Decisions
What makes a good test
Snapshot+httptestIdP-double state, assert the observable outputs — theCheckResponse(status;denied_response302 +Location+Set-Cookie;ok_responseinjected headers +headers_to_remove), the HTTP response (status,Set-Cookie,Location), and the IdP double's received request form (the grant type,audience, whether a secret was sent) and its invocation count (the cache hit/miss). Do not assert on private struct fields, internal call order, or loop mechanics.Seams
ExternalAuthorization.Checkservice + the plain-HTTP callback/logout listener, driven in-process against a constructedSnapshot(built via the existing pure snapshot builder, spec #1 Seam 2) withhttptestIdP token-endpoint doubles (the existingstubIdPpattern, spec #1 Seam 3). One seam covers: OIDCCheck(JWKS validation, lazy refresh, claim→header injection,302, status mapping), ExchangeCheck(subject-token read, RFC 8693 grant,Authorizationinjection + consume, per-replica cache), callback (state/nonce validation, App resolution, code→tokens, set Session cookie,302), logout (clear cookie, RP-initiated), and the fail-closed status model. No envtest needed — it consumes the in-memory Snapshot, not the K8s API. JWKS validation is exercised by generating a test ES256 key (go-jose), putting the public key in the Snapshot'sJWKS, and signing a fixtureid_token.ReferenceGrantcontroller (reuses the existing envtest suite pattern). A controller, so its reconcile behavior (watch Routes, build the grant, update as Routes change) is tested in the existing envtest + Ginkgo/Gomega suite, like the existingcontroller/*/suite_test.go.end_session_endpointresolve amendment (reuses spec #1's IdP-resolve seam). The leader's discovery resolve now also extractsend_session_endpoint; tested via the existingstubIdPhttptestdouble (a new discovery assertion) + the existing envtest leader path. Not a new seam.Prior art
stubIdPhttptestIdP double (spec #1 Seam 3), the pure snapshot builder (spec #1 Seam 2), and the per-package envtest + Ginkgo/Gomegasuite_test.go.Seam count
Out of Scope
end_session_endpointaddition.denied_responsegate, we don't design the gateway.Further Notes
authorization_code+refresh_token(both use the client secret, available via the per-App Secret watch) andtoken-exchange/RFC 8693 (which rejects the client secret).Authorization+ per-replica cache), 0010 (callbackstate/nonce cookie), 0011 (fail-closed status model) capture the decisions this spec implements; ADR 0003 the endpoints; ADR 0005/0006 the state model + failure model.docs/references/kanidm-service-account-token-exchange.md; read it before working on the Exchange Flow.ReferenceGrantis controller-managed because Apps live in many namespaces and the grant must track them; the Services are static because they are fixed (one per endpoint, cluster-wide).end_session_endpointamendment is a small follow-on to spec #1's IdP-resolve step (discovery is already fetched; extract one more field).Closing: the spec's build scope is complete. All nine tickets are delivered and merged to master:
end_session_endpoint) — PR #22The one spec item not performed (the lab-gateway verification gate) and the code-review follow-ups from the build tickets are tracked in #30.
Build scope complete (PRs #21-#29 merged); remaining verification gate + review follow-ups tracked in #30.