Spec: The flow-serving bridge (gRPC ext_authz Check + OIDC/Exchange flows + callback/logout listener) #11

Closed
opened 2026-09-14 16:53:51 +00:00 by ginjiruu · 2 comments
Owner

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 ExternalAuth filter, 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_authz server that answers the gateway's Check callouts, 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_authz server that consumes the Snapshot and serves the flows, plus a plain-HTTP callback/logout listener, plus the Services and a controller-managed ReferenceGrant.

  • Three endpoints (ADR 0003): :8083 gRPC ext_authz OIDC, :8084 gRPC ext_authz Exchange, :8082 plain HTTP (callback/logout), exposed as three Services in authz-bridge-system plus a controller-managed ReferenceGrant.
  • OIDC Flow (ADR 0007/0008): a stateless Session (a plain cookie holding the id_token + refresh_token); per-request id_token validation against the JWKS; claim→header injection; a lazy refresh on the Check path; a 302 to the IdP via denied_response.
  • Exchange Flow (ADR 0009): the RFC 8693 grant (no client secret; audience = the App's own clientID), the exchanged token injected as Authorization, a per-replica token cache.
  • Callback / Logout (ADR 0010): the state/nonce cookie (set on the redirect) resolves the App and defeats CSRF; the code is exchanged for tokens; the Session cookie is set; logout clears it (or is RP-initiated).
  • Fail-closed status model (ADR 0011): 403 (no App) / 5xx (unavailable, secretReady=false) / 302 (re-login) / 401 (no subject token); a failed exchange never mis-authorizes.
  • State-model amendment (a small follow-on to spec #1): the leader's discovery resolve also extracts end_session_endpoint, carried in the Snapshot, so EndSession (RP-initiated logout) works.

User Stories

  1. As an App team, I want the Bridge to answer the gateway's ext_authz Check callout for my App's OIDC-protected requests, so that my backend is gated behind sign-in.
  2. As a user, I want to be redirected (302) to the Kanidm authorization endpoint when I have no Session, so that I can sign in.
  3. As an App team, I want the Bridge to set a state/nonce cookie on the redirect (via the denied_response), so that the callback can resolve my App and defeat CSRF.
  4. As a user, I want the Bridge to validate my id_token against the JWKS on every Check (signature + exp + iss + aud), so that my sign-in is verified on each request.
  5. As an App team, I want the Bridge to inject my id_token's claims as headers on the request forwarded to my backend (per the OidcInjection claim→header mapping), so that my backend sees the identity.
  6. As a user, I want an expired id_token to be transparently renewed via the refresh_token (a lazy refresh on the Check path), so that I'm not bounced to Kanidm every 15 minutes.
  7. As an App team, I want the Bridge to re-issue the Session cookie after a refresh (via response_headers_to_add), so that the renewed tokens persist in the browser.
  8. As an operator, I want a failed refresh to invalidate the Session and 302 to the authorization endpoint (fail-closed), so that a dead session is re-authenticated, not silently served.
  9. As an operator, I want a Check for a request whose Host matches no App to be denied with 403, so that an unknown App is a "forbidden," not a mis-authorization.
  10. As an operator, I want a Check for an App whose secretReady=false to be served as 5xx, so that a broken config is "unavailable," not "forbidden."
  11. As an operator, I want the Session's lifetime to be bounded by Kanidm's session/refresh lifetime (8h) and capped by the OidcSession.TTL, so that I understand how long a sign-in lasts.
  12. As an operator, I want revocation to be floored at 15 min by Kanidm's refresh check-in, so that a revoked account stops working within 15 min.
  13. As an App team, I want the Bridge to read the presented Service Account Token from the configured header/prefix (default Authorization / Bearer ), so that I control where the machine token comes from.
  14. As a service account, I want the Bridge to exchange my API token for OAuth2 tokens via the RFC 8693 grant (no client secret; audience = the App's own clientID; the policy's scopes), so that I can access the backend as my App.
  15. As an App team, I want the Bridge to inject the exchanged access_token as Authorization: Bearer <token> and consume (headers_to_remove) the incoming Service Account Token, so that my backend sees the exchanged token, not the subject token.
  16. As an operator, I want a Check with a missing/malformed subject token to be denied with 401, so that a caller that presented no valid subject token is told to.
  17. As an operator, I want an RFC 8693 rejection by Kanidm (or an unreachable token endpoint) to be served as 5xx (fail-closed), so that a failed exchange never mis-authorizes.
  18. As an operator, I want the Bridge to cache exchanged tokens per-replica (TTL ≤ the access token's exp, keyed by subject token + scopes), so that repeated requests don't hammer the token endpoint.
  19. As an operator, I want a cache miss/eviction to simply re-exchange (no correctness impact), so that the cache is a pure performance optimization.
  20. As an App team, I want the Exchange Flow's target (audience) to be implicitly my Client's own clientID, so that machine tokens land in my App's own audience with no extra config.
  21. As a user, I want the IdP's redirect back to my App's registered callbackURL to be handled by the Bridge's :8082 listener, so that my sign-in completes.
  22. As an App team, I want the Bridge to validate the callback's state param against the state cookie (CSRF) before exchanging the code, so that a forged callback is rejected.
  23. As an App team, I want the Bridge to resolve the App from the state cookie (appKey), so that the callback works without relying on the gateway forwarding the Host to the HTTP backend.
  24. As an App team, I want the Bridge to exchange the code for tokens (the authorization_code grant, using the client secret) and set the Session cookie (id_token + refresh_token), so that my user is signed in.
  25. As a user, I want the callback to 302 me back to the URL I was trying to reach (returnTo), so that I'm returned to my original request.
  26. As an operator, I want the returnTo to live in the plain state cookie (not the state URL param), so that the return URL isn't logged by Kanidm or leaked in the redirect.
  27. As an App team, I want the Bridge to clear the Session cookie on logout (local-only, the default), so that my user's sign-in ends with no extra config.
  28. As an App team, I want opt-in RP-initiated logout (EndSession=true) to 302 the browser to Kanidm's end_session_endpoint with id_token_hint, so that I can end the session at the IdP too.
  29. As an operator, I want the end_session_endpoint to be resolved from discovery and carried in the resolved state, so that RP-initiated logout works without a per-request discovery fetch.
  30. As a developer, I want the Bridge to use envoyproxy/go-control-plane for the ext_authz Check proto, so that I use the canonical, maintained contract.
  31. As a developer, I want the Bridge to use go-jose for JWKS + ES256 verification + claim checks, so that token validation is correct and not hand-rolled.
  32. As a developer, I want the flow-serving servers to run in the same binary as the controller (sharing the per-replica Snapshot), so that there's one process and one shared read path.
  33. As an operator, I want the three Services (:8082/:8083/:8084) in authz-bridge-system to be static config (part of the install bundle), so that I deploy them with kubectl.
  34. As an operator, I want the ReferenceGrant to be controller-managed (watching Routes, granting each Route's namespace permission to reference the bridge's Services, updated as Routes change), so that cross-namespace backendRefs are authorized without hand-wiring.
  35. As an operator, I want the Bridge's status vocabulary to be fail-closed (403 ownership / 5xx unavailable / 302 re-login / 401 no subject token), so that a failure never mis-authorizes.
  36. As an operator, I want the OIDC Flow's requirement (the gateway honors denied_response 302 + Location + Set-Cookie, and response_headers_to_add Set-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).
  37. As an operator, I want the leader's discovery resolve to also extract end_session_endpoint and carry it in the Snapshot, so that the OIDC section is complete for RP-initiated logout.

Implementation Decisions

Build surface

  • Add envoyproxy/go-control-plane for the ext_authz Check proto (the canonical home of envoy/service/auth/v3; grpc-go is already a transitive dep). Add github.com/go-jose/go-jose/v4 for 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

  • The manager entrypoint is extended so the single binary runs the controller (reconcilers + leader-gated Registry builder + JWKS refresh), the per-replica Snapshot mirror, and the flow-serving servers — all sharing the per-replica Snapshot. The gRPC ext_authz Check service is registered on :8083 (OIDC) and :8084 (Exchange); the plain-HTTP callback/logout listener is on :8082. App resolution is by hostname via AppForHostname for the gRPC Check; the callback resolves the App from the state cookie's appKey.

OIDC Flow (:8083) — ADR 0007, ADR 0008

  • The Session is a plain (unencrypted) cookie holding the id_token and the refresh_token; no server-side session store.
  • Valid Session: validate the id_token against the Snapshot's JWKS (go-jose: match the key by kid, verify the ES256 signature, check exp/iss/aud); inject the id_token's claims as headers per the OidcInjection claimheader mapping (OkHttpResponse.headers); return OK.
  • No/invalid Session (after a refresh failure): return a 302 to the authorization_endpoint via denied_response (status=302 + Location), and set the state/nonce cookie on the same denied_response (Set-Cookie).
  • Refresh flow runs lazily on the Check path: when id_token.exp − now < margin (≈60s), use the refresh_token grant (client secret) to renew, re-issue the Session cookie via OkHttpResponse.response_headers_to_add (Set-Cookie), then validate/inject the new id_token. On refresh failure, invalidate the Session and 302 (fail-closed).
  • The effective Session is bounded by the IdP's session/refresh lifetime (8h Kanidm session; the refresh re-verifies it), capped by the OidcSession.TTL cookie Max-Age; revocation is floored at 15 min by Kanidm's refresh check-in.

Exchange Flow (:8084) — ADR 0009

  • Read the presented Service Account Token from the configured header/prefix/tokenType (default Authorization / Bearer / access_token). Missing/malformed → 401.
  • Perform the RFC 8693 grant (no client secret; audience = the App's own clientID; scopes from the TokenExchangePolicy). Inject the exchanged access_token as Authorization: Bearer <token>; headers_to_remove the incoming Authorization (consume the subject token). RFC 8693 rejected by Kanidm, or the token endpoint unreachable → 5xx (fail-closed).
  • A per-replica, TTL-bounded (≤ the access token's exp) LRU cache keyed by (subject_token, scopes, audience); a miss/eviction just re-exchanges (no correctness impact).

Callback (:8082) — ADR 0010

  • The state/nonce cookie (appKey|nonce|returnTo) is set on the Check's 302; the callback validates state param == nonce (CSRF) and resolves the App from appKey. It exchanges the code (the authorization_code grant, using the client secret) → sets the plain Session cookie (id_token + refresh_token) → 302 to returnTo. returnTo lives in the plain cookie, not the state URL param, so the return URL is not logged by the IdP or leaked in the redirect.

Logout (:8082)

  • Clear the Session cookie (Set-Cookie with Max-Age=0). EndSession=true (opt-in) → 302 to the end_session_endpoint with id_token_hint (RP-initiated). EndSession=false (default) → clear + 302 back (local-only, zero-config).

Fail-closed status model — ADR 0011

Case Status
No App resolved (no Host match) 403
App resolved but secretReady=false 5xx (503)
OIDC no/invalid Session after a refresh failure 302 (to the authorization endpoint)
OIDC valid Session OK + injected claim headers
Exchange missing/malformed subject token 401
Exchange RFC 8693 rejected by the IdP, or token endpoint unreachable 5xx (502/503)

Manifests

  • The three Services (:8082/:8083/:8084) in authz-bridge-system are static config (part of the install bundle).
  • The ReferenceGrant is controller-managed: a new reconciler watches HTTPRoutes (and GRPCRoutes) across namespaces and owns a ReferenceGrant granting each Route's namespace permission to reference the Bridge's Services in authz-bridge-system, updated as Routes appear/disappear.

State-model amendment (follow-on to spec #1)

  • The leader's IdP discovery resolve also extracts end_session_endpoint; the OIDC section of the Registry Entry and 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 resolved token_endpoint (every replica needs IdP token-endpoint egress; the leader still owns discovery + JWKS).

Gateway verification gate

  • The gateway must honor denied_response (302 + Location + Set-Cookie) and response_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 no 302). cookie must be listed in the OIDC rule's allowedHeaders (ADR 0003).

Testing Decisions

What makes a good test

  • Test external behavior only: given a Snapshot + httptest IdP-double state, assert the observable outputs — the CheckResponse (status; denied_response 302 + Location + Set-Cookie; ok_response injected 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

  • Seam A — the flow-serving server (the one new, highest seam). The gRPC ExternalAuthorization.Check service + the plain-HTTP callback/logout listener, driven in-process against a constructed Snapshot (built via the existing pure snapshot builder, spec #1 Seam 2) with httptest IdP token-endpoint doubles (the existing stubIdP pattern, spec #1 Seam 3). One seam covers: OIDC Check (JWKS validation, lazy refresh, claim→header injection, 302, status mapping), Exchange Check (subject-token read, RFC 8693 grant, Authorization injection + 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's JWKS, and signing a fixture id_token.
  • Seam B — the ReferenceGrant controller (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 existing controller/*/suite_test.go.
  • Seam C — the end_session_endpoint resolve amendment (reuses spec #1's IdP-resolve seam). The leader's discovery resolve now also extracts end_session_endpoint; tested via the existing stubIdP httptest double (a new discovery assertion) + the existing envtest leader path. Not a new seam.

Prior art

  • The existing stubIdP httptest IdP double (spec #1 Seam 3), the pure snapshot builder (spec #1 Seam 2), and the per-package envtest + Ginkgo/Gomega suite_test.go.

Seam count

  • One new seam (A); B and C reuse existing seams/patterns.

Out of Scope

  • The state model (Registry / Snapshot / mirror / leader) — done (spec #1); only the end_session_endpoint addition.
  • Leader election (standard controller-runtime Lease; not re-designed).
  • Per-App Services + allocated ports (rejected in ADR 0003).
  • The gateway itself — the design is gateway-agnostic; we verify the lab gateway honors the denied_response gate, we don't design the gateway.
  • A session that outlives the IdP's session (the Session is bounded by Kanidm's session/refresh lifetime; no separate long-lived mechanism).
  • Any cross-replica session or state sharing (the design is stateless by construction; the only per-replica state is the ephemeral Exchange token cache).
  • Basic auth / API key / JWT flows (future Flow kinds; ADR 0004).
  • The 1 MiB Registry ceiling concern (lab scale).

Further Notes

  • The Bridge performs three token-endpoint grants — authorization_code + refresh_token (both use the client secret, available via the per-App Secret watch) and token-exchange/RFC 8693 (which rejects the client secret).
  • The revocation window is floored at 15 min by Kanidm's refresh check-in, not by the token lifetime (ADR 0007).
  • ADRs 0007 (stateless Session, id + refresh, plain cookie), 0008 (lazy refresh), 0009 (Exchange Authorization + per-replica cache), 0010 (callback state/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.
  • The Exchange grant semantics are documented in docs/references/kanidm-service-account-token-exchange.md; read it before working on the Exchange Flow.
  • The ReferenceGrant is 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).
  • The end_session_endpoint amendment is a small follow-on to spec #1's IdP-resolve step (discovery is already fetched; extract one more field).
## 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 `ExternalAuth` filter, 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_authz` server that answers the gateway's `Check` callouts, 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_authz` server that consumes the Snapshot and serves the flows, plus a plain-HTTP callback/logout listener, plus the Services and a controller-managed `ReferenceGrant`. - **Three endpoints** (ADR 0003): `:8083` gRPC `ext_authz` OIDC, `:8084` gRPC `ext_authz` Exchange, `:8082` plain HTTP (callback/logout), exposed as three Services in `authz-bridge-system` plus a controller-managed `ReferenceGrant`. - **OIDC Flow** (ADR 0007/0008): a stateless Session (a plain cookie holding the `id_token` + `refresh_token`); per-request `id_token` validation against the JWKS; claim→header injection; a lazy refresh on the `Check` path; a `302` to the IdP via `denied_response`. - **Exchange Flow** (ADR 0009): the RFC 8693 grant (no client secret; `audience` = the App's own clientID), the exchanged token injected as `Authorization`, a per-replica token cache. - **Callback / Logout** (ADR 0010): the `state`/nonce cookie (set on the redirect) resolves the App and defeats CSRF; the `code` is exchanged for tokens; the Session cookie is set; logout clears it (or is RP-initiated). - **Fail-closed status model** (ADR 0011): 403 (no App) / 5xx (unavailable, `secretReady=false`) / 302 (re-login) / 401 (no subject token); a failed exchange never mis-authorizes. - **State-model amendment** (a small follow-on to spec #1): the leader's discovery resolve also extracts `end_session_endpoint`, carried in the Snapshot, so `EndSession` (RP-initiated logout) works. ## User Stories 1. As an App team, I want the Bridge to answer the gateway's `ext_authz` `Check` callout for my App's OIDC-protected requests, so that my backend is gated behind sign-in. 2. As a user, I want to be redirected (302) to the Kanidm authorization endpoint when I have no Session, so that I can sign in. 3. As an App team, I want the Bridge to set a `state`/nonce cookie on the redirect (via the `denied_response`), so that the callback can resolve my App and defeat CSRF. 4. As a user, I want the Bridge to validate my `id_token` against the JWKS on every `Check` (signature + `exp` + `iss` + `aud`), so that my sign-in is verified on each request. 5. As an App team, I want the Bridge to inject my `id_token`'s claims as headers on the request forwarded to my backend (per the `OidcInjection` claim→header mapping), so that my backend sees the identity. 6. As a user, I want an expired `id_token` to be transparently renewed via the `refresh_token` (a lazy refresh on the `Check` path), so that I'm not bounced to Kanidm every 15 minutes. 7. As an App team, I want the Bridge to re-issue the Session cookie after a refresh (via `response_headers_to_add`), so that the renewed tokens persist in the browser. 8. As an operator, I want a failed refresh to invalidate the Session and 302 to the authorization endpoint (fail-closed), so that a dead session is re-authenticated, not silently served. 9. As an operator, I want a `Check` for a request whose Host matches no App to be denied with 403, so that an unknown App is a "forbidden," not a mis-authorization. 10. As an operator, I want a `Check` for an App whose `secretReady=false` to be served as 5xx, so that a broken config is "unavailable," not "forbidden." 11. As an operator, I want the Session's lifetime to be bounded by Kanidm's session/refresh lifetime (8h) and capped by the `OidcSession.TTL`, so that I understand how long a sign-in lasts. 12. As an operator, I want revocation to be floored at 15 min by Kanidm's refresh check-in, so that a revoked account stops working within 15 min. 13. As an App team, I want the Bridge to read the presented Service Account Token from the configured header/prefix (default `Authorization` / `Bearer `), so that I control where the machine token comes from. 14. As a service account, I want the Bridge to exchange my API token for OAuth2 tokens via the RFC 8693 grant (no client secret; `audience` = the App's own clientID; the policy's scopes), so that I can access the backend as my App. 15. As an App team, I want the Bridge to inject the exchanged `access_token` as `Authorization: Bearer <token>` and consume (`headers_to_remove`) the incoming Service Account Token, so that my backend sees the exchanged token, not the subject token. 16. As an operator, I want a `Check` with a missing/malformed subject token to be denied with 401, so that a caller that presented no valid subject token is told to. 17. As an operator, I want an RFC 8693 rejection by Kanidm (or an unreachable token endpoint) to be served as 5xx (fail-closed), so that a failed exchange never mis-authorizes. 18. As an operator, I want the Bridge to cache exchanged tokens per-replica (TTL ≤ the access token's exp, keyed by subject token + scopes), so that repeated requests don't hammer the token endpoint. 19. As an operator, I want a cache miss/eviction to simply re-exchange (no correctness impact), so that the cache is a pure performance optimization. 20. As an App team, I want the Exchange Flow's target (`audience`) to be implicitly my Client's own clientID, so that machine tokens land in my App's own audience with no extra config. 21. As a user, I want the IdP's redirect back to my App's registered `callbackURL` to be handled by the Bridge's `:8082` listener, so that my sign-in completes. 22. As an App team, I want the Bridge to validate the callback's `state` param against the `state` cookie (CSRF) before exchanging the `code`, so that a forged callback is rejected. 23. As an App team, I want the Bridge to resolve the App from the `state` cookie (`appKey`), so that the callback works without relying on the gateway forwarding the Host to the HTTP backend. 24. As an App team, I want the Bridge to exchange the `code` for tokens (the `authorization_code` grant, using the client secret) and set the Session cookie (`id_token` + `refresh_token`), so that my user is signed in. 25. As a user, I want the callback to 302 me back to the URL I was trying to reach (`returnTo`), so that I'm returned to my original request. 26. As an operator, I want the `returnTo` to live in the plain `state` cookie (not the `state` URL param), so that the return URL isn't logged by Kanidm or leaked in the redirect. 27. As an App team, I want the Bridge to clear the Session cookie on logout (local-only, the default), so that my user's sign-in ends with no extra config. 28. As an App team, I want opt-in RP-initiated logout (`EndSession=true`) to 302 the browser to Kanidm's `end_session_endpoint` with `id_token_hint`, so that I can end the session at the IdP too. 29. As an operator, I want the `end_session_endpoint` to be resolved from discovery and carried in the resolved state, so that RP-initiated logout works without a per-request discovery fetch. 30. As a developer, I want the Bridge to use `envoyproxy/go-control-plane` for the `ext_authz` `Check` proto, so that I use the canonical, maintained contract. 31. As a developer, I want the Bridge to use `go-jose` for JWKS + ES256 verification + claim checks, so that token validation is correct and not hand-rolled. 32. As a developer, I want the flow-serving servers to run in the same binary as the controller (sharing the per-replica Snapshot), so that there's one process and one shared read path. 33. As an operator, I want the three Services (`:8082`/`:8083`/`:8084`) in `authz-bridge-system` to be static config (part of the install bundle), so that I deploy them with `kubectl`. 34. As an operator, I want the `ReferenceGrant` to be controller-managed (watching Routes, granting each Route's namespace permission to reference the bridge's Services, updated as Routes change), so that cross-namespace `backendRefs` are authorized without hand-wiring. 35. As an operator, I want the Bridge's status vocabulary to be fail-closed (403 ownership / 5xx unavailable / 302 re-login / 401 no subject token), so that a failure never mis-authorizes. 36. As an operator, I want the OIDC Flow's requirement (the gateway honors `denied_response` 302 + `Location` + `Set-Cookie`, and `response_headers_to_add` `Set-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). 37. As an operator, I want the leader's discovery resolve to also extract `end_session_endpoint` and carry it in the Snapshot, so that the OIDC section is complete for RP-initiated logout. ## Implementation Decisions **Build surface** - Add `envoyproxy/go-control-plane` for the `ext_authz` `Check` proto (the canonical home of `envoy/service/auth/v3`; `grpc-go` is already a transitive dep). Add `github.com/go-jose/go-jose/v4` for 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** - The manager entrypoint is extended so the single binary runs the controller (reconcilers + leader-gated Registry builder + JWKS refresh), the per-replica Snapshot mirror, and the flow-serving servers — all sharing the per-replica Snapshot. The gRPC `ext_authz` `Check` service is registered on `:8083` (OIDC) and `:8084` (Exchange); the plain-HTTP callback/logout listener is on `:8082`. App resolution is by hostname via `AppForHostname` for the gRPC `Check`; the callback resolves the App from the `state` cookie's `appKey`. **OIDC Flow (`:8083`)** — ADR 0007, ADR 0008 - The **Session** is a **plain (unencrypted) cookie** holding the `id_token` and the `refresh_token`; no server-side session store. - Valid Session: validate the `id_token` against the Snapshot's `JWKS` (go-jose: match the key by `kid`, verify the ES256 signature, check `exp`/`iss`/`aud`); inject the `id_token`'s claims as headers per the `OidcInjection` `claim`→`header` mapping (`OkHttpResponse.headers`); return `OK`. - No/invalid Session (after a refresh failure): return a `302` to the `authorization_endpoint` via `denied_response` (`status=302` + `Location`), and set the `state`/nonce cookie on the same `denied_response` (`Set-Cookie`). - **Refresh flow** runs **lazily on the `Check` path**: when `id_token.exp − now < margin` (≈60s), use the `refresh_token` grant (client secret) to renew, re-issue the Session cookie via `OkHttpResponse.response_headers_to_add` (`Set-Cookie`), then validate/inject the *new* `id_token`. On refresh failure, invalidate the Session and `302` (fail-closed). - The effective Session is bounded by the IdP's session/refresh lifetime (8h Kanidm session; the refresh re-verifies it), capped by the `OidcSession.TTL` cookie Max-Age; revocation is floored at 15 min by Kanidm's refresh check-in. **Exchange Flow (`:8084`)** — ADR 0009 - Read the presented Service Account Token from the configured header/prefix/tokenType (default `Authorization` / `Bearer ` / `access_token`). Missing/malformed → `401`. - Perform the **RFC 8693 grant** (no client secret; `audience` = the App's own clientID; scopes from the `TokenExchangePolicy`). Inject the exchanged `access_token` as `Authorization: Bearer <token>`; `headers_to_remove` the incoming `Authorization` (consume the subject token). RFC 8693 rejected by Kanidm, or the token endpoint unreachable → `5xx` (fail-closed). - A **per-replica, TTL-bounded (≤ the access token's exp) LRU cache** keyed by `(subject_token, scopes, audience)`; a miss/eviction just re-exchanges (no correctness impact). **Callback (`:8082`)** — ADR 0010 - The **`state`/nonce cookie** (`appKey|nonce|returnTo`) is set on the `Check`'s `302`; the callback validates `state` param == `nonce` (CSRF) and resolves the App from `appKey`. It exchanges the `code` (the `authorization_code` grant, using the client secret) → sets the plain Session cookie (`id_token` + `refresh_token`) → `302` to `returnTo`. `returnTo` lives in the plain cookie, not the `state` URL param, so the return URL is not logged by the IdP or leaked in the redirect. **Logout (`:8082`)** - Clear the Session cookie (`Set-Cookie` with `Max-Age=0`). `EndSession=true` (opt-in) → `302` to the `end_session_endpoint` with `id_token_hint` (RP-initiated). `EndSession=false` (default) → clear + `302` back (local-only, zero-config). **Fail-closed status model** — ADR 0011 | Case | Status | |---|---| | No App resolved (no Host match) | `403` | | App resolved but `secretReady=false` | `5xx` (503) | | OIDC no/invalid Session after a refresh failure | `302` (to the authorization endpoint) | | OIDC valid Session | `OK` + injected claim headers | | Exchange missing/malformed subject token | `401` | | Exchange RFC 8693 rejected by the IdP, or token endpoint unreachable | `5xx` (502/503) | **Manifests** - The three Services (`:8082`/`:8083`/`:8084`) in `authz-bridge-system` are **static config** (part of the install bundle). - The `ReferenceGrant` is **controller-managed**: a new reconciler watches `HTTPRoute`s (and `GRPCRoute`s) across namespaces and owns a `ReferenceGrant` granting each Route's namespace permission to reference the Bridge's Services in `authz-bridge-system`, updated as Routes appear/disappear. **State-model amendment (follow-on to spec #1)** - The leader's IdP discovery resolve also extracts `end_session_endpoint`; the `OIDC` section of the Registry `Entry` and 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 resolved `token_endpoint` (every replica needs IdP token-endpoint egress; the leader still owns discovery + JWKS). **Gateway verification gate** - The gateway must honor `denied_response` (`302` + `Location` + `Set-Cookie`) and `response_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 no `302`). `cookie` must be listed in the OIDC rule's `allowedHeaders` (ADR 0003). ## Testing Decisions **What makes a good test** - Test **external behavior only**: given a `Snapshot` + `httptest` IdP-double state, assert the observable outputs — the `CheckResponse` (status; `denied_response` 302 + `Location` + `Set-Cookie`; `ok_response` injected 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** - **Seam A — the flow-serving server (the one new, highest seam).** The gRPC `ExternalAuthorization.Check` service + the plain-HTTP callback/logout listener, driven in-process against a **constructed `Snapshot`** (built via the existing pure snapshot builder, spec #1 Seam 2) with **`httptest` IdP token-endpoint doubles** (the existing `stubIdP` pattern, spec #1 Seam 3). One seam covers: OIDC `Check` (JWKS validation, lazy refresh, claim→header injection, `302`, status mapping), Exchange `Check` (subject-token read, RFC 8693 grant, `Authorization` injection + 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's `JWKS`, and signing a fixture `id_token`. - **Seam B — the `ReferenceGrant` controller (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 existing `controller/*/suite_test.go`. - **Seam C — the `end_session_endpoint` resolve amendment (reuses spec #1's IdP-resolve seam).** The leader's discovery resolve now also extracts `end_session_endpoint`; tested via the existing `stubIdP` `httptest` double (a new discovery assertion) + the existing envtest leader path. Not a new seam. **Prior art** - The existing `stubIdP` `httptest` IdP double (spec #1 Seam 3), the pure snapshot builder (spec #1 Seam 2), and the per-package envtest + Ginkgo/Gomega `suite_test.go`. **Seam count** - One new seam (A); B and C reuse existing seams/patterns. ## Out of Scope - The state model (Registry / Snapshot / mirror / leader) — done (spec #1); only the `end_session_endpoint` addition. - Leader election (standard controller-runtime Lease; not re-designed). - Per-App Services + allocated ports (rejected in ADR 0003). - The gateway itself — the design is gateway-agnostic; we *verify* the lab gateway honors the `denied_response` gate, we don't design the gateway. - A session that outlives the IdP's session (the Session is bounded by Kanidm's session/refresh lifetime; no separate long-lived mechanism). - Any cross-replica session or state sharing (the design is stateless by construction; the only per-replica state is the ephemeral Exchange token cache). - Basic auth / API key / JWT flows (future Flow kinds; ADR 0004). - The 1 MiB Registry ceiling concern (lab scale). ## Further Notes - The Bridge performs **three** token-endpoint grants — `authorization_code` + `refresh_token` (both use the client secret, available via the per-App Secret watch) and `token-exchange`/RFC 8693 (which rejects the client secret). - The **revocation window is floored at 15 min** by Kanidm's refresh check-in, not by the token lifetime (ADR 0007). - ADRs **0007** (stateless Session, id + refresh, plain cookie), **0008** (lazy refresh), **0009** (Exchange `Authorization` + per-replica cache), **0010** (callback `state`/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. - The Exchange grant semantics are documented in `docs/references/kanidm-service-account-token-exchange.md`; read it before working on the Exchange Flow. - The `ReferenceGrant` is 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). - The `end_session_endpoint` amendment is a small follow-on to spec #1's IdP-resolve step (discovery is already fetched; extract one more field).
Author
Owner

Closing: the spec's build scope is complete. All nine tickets are delivered and merged to master:

  • #12 Build surface — PR #21
  • #13 State-model amendment (end_session_endpoint) — PR #22
  • #14 OIDC steady-state Check — PR #23
  • #15 Exchange Check — PR #24
  • #16 OIDC callback — PR #25
  • #17 OIDC lazy refresh — PR #26
  • #18 Exchange per-replica token cache — PR #27
  • #19 Logout — PR #28
  • #20 Services (static) + ReferenceGrant (controller-managed) — PR #29

The one spec item not performed (the lab-gateway verification gate) and the code-review follow-ups from the build tickets are tracked in #30.

Closing: the spec's build scope is complete. All nine tickets are delivered and merged to master: - #12 Build surface — PR #21 - #13 State-model amendment (`end_session_endpoint`) — PR #22 - #14 OIDC steady-state Check — PR #23 - #15 Exchange Check — PR #24 - #16 OIDC callback — PR #25 - #17 OIDC lazy refresh — PR #26 - #18 Exchange per-replica token cache — PR #27 - #19 Logout — PR #28 - #20 Services (static) + ReferenceGrant (controller-managed) — PR #29 The one spec item not performed (the lab-gateway verification gate) and the code-review follow-ups from the build tickets are tracked in #30.
Author
Owner

Build scope complete (PRs #21-#29 merged); remaining verification gate + review follow-ups tracked in #30.

Build scope complete (PRs #21-#29 merged); remaining verification gate + review follow-ups tracked in #30.
Sign in to join this conversation.
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#11
No description provided.