- Go 92.8%
- Makefile 5%
- Shell 2.2%
Add MachineSpec.PassthroughExpression CEL field (default false, opt-in). Verified id_tokens (JWKS, iss, aud, exp) are evaluated with cel-go (token.header, token.claims, oidc, request); true -> passthrough with original Authorization and identity headers, false -> RFC 8693 exchange. CEL programs are compiled and cached per AuthzApp (sync.Map) and validated at store Put. Opaque service-account tokens skip CEL and go directly to exchange. Fixes kubectl via k8s-hub without client header changes. Co-authored-by: Muse Spark |
||
|---|---|---|
| .devcontainer | ||
| .github/workflows | ||
| api/v1alpha1 | ||
| cmd | ||
| config | ||
| examples | ||
| hack | ||
| internal | ||
| test | ||
| tickets | ||
| .custom-gcl.yml | ||
| .gitignore | ||
| .golangci.yml | ||
| AGENTS.md | ||
| go.mod | ||
| go.sum | ||
| Makefile | ||
| PROJECT | ||
| README.md | ||
| skaffold.yaml | ||
| spec.md | ||
authz-bridge
A single, multi-tenant authz bridge: a Go service that fronts many applications with authentication and authorization at the Gateway API boundary, replacing the per-app OAuth2 proxy / gateway-OAuth2-extension pattern.
It speaks the Envoy ext_authz gRPC protocol (envoy.service.auth.v3.Authorization), acts as the OIDC
Relying Party against any OIDC provider for browser login, and performs RFC 8693 token
exchange for machine clients. Configuration is declarative and per-app via
the AuthzApp custom resource; one bridge serves every configured app.
Why gRPC. The previous HTTP transport used a
path_prefix(ExternalAuth.http.path = /check) which prepended to the client path (a request to/was forwarded as/check/) and relied on Istio'sX-Original-URL/X-Forwarded-Uriheaders which Envoy does not send — both produced404s for the first protected request. The gRPC transport removes both defects: it has nopath_prefixand the original path arrives as the structuredattributes.http.pathfield.
How it works
The cluster's Gateway API ExternalAuth filter (protocol: GRPC) sends every request to a
protected app to the bridge's gRPC Authorization.Check. The bridge decides:
| Caller | gRPC status | HTTP status (Envoy → client) | Detail |
|---|---|---|---|
| Valid session for the matched app | OK (0) |
200 |
identity headers (Remote-User, Remote-Groups, … from claimsToHeaders) |
| Browser, no session | OK (0) |
302 |
Location to the IdP authorize endpoint (callback on the app's own hostname) |
| API caller, no session | UNAUTHENTICATED (16) |
401 |
never a redirect |
Machine client (Authorization: Bearer …) |
OK (0) |
200 |
RFC 8693 exchange; exchanged token in the configured header, or 401 on failure |
| Unknown host / misconfiguration / internal error | PERMISSION_DENIED (7) |
403 |
fail closed, never allow |
The bridge matches the request Host header to an AuthzApp in its in-memory
config store, populated by a controller that watches AuthzApp objects.
The AuthzApp resource
apiVersion: authz.animeteamspeak.moe/v1alpha1
kind: AuthzApp
metadata: { name: media-admin, namespace: media }
spec:
host: admin.media.example.test
allowedRequestHeaders: [Accept, Authorization, Cookie] # default
oidc:
issuerURI: https://auth.example.test/oauth2/openid/media
clientID: media
clientSecretRef: { name: media-oauth-secrets, key: client-secret }
scopes: [openid, email]
claimsToHeaders:
- { claim: preferred_username, header: Remote-User }
- { claim: groups, header: Remote-Groups }
session: { cookieName: _media_admin, ttl: 12h }
machine:
exchange: true
audience: https://media.example.test/jellyfin
scope: "" # optional downstream scope
injectHeader: Authorization # default
Sessions are per-app: each app gets its own cookie name and session namespace,
and a session for one app never authorizes another. clientSecretRef resolves
a Kubernetes Secret at runtime (compatible with external secret management
such as External Secrets Operator); a missing secret denies at check time.
Building and testing
make manifests generate # regenerate CRDs/RBAC/DeepCopy after editing types
make build # go build
make test # unit tests (fake client + fake Kanidm; no cluster needed)
go test ./... # same, without the envtest binary download
The tests use a small standards-shaped fake OIDC provider (test/fakekanidm) as the
OIDC seam: discovery, JWKS, /authorize, /token, and the RFC 8693 exchange
endpoint. go test ./... needs no Kubernetes cluster and no envtest binaries.
The controller tests use a fake client (no envtest). The test/e2e suite is
the standard kubebuilder kind-cluster e2e and requires an isolated cluster.
Running locally
The binary needs a Kubernetes API server (for AuthzApp objects and Secrets)
and a reachable OIDC issuer. The intended local setup is a dev cluster plus a
fake or real OIDC provider:
# against a dev cluster with the CRD installed and an AuthzApp applied:
make run IMG=your-registry/authz-bridge:tag # or go run ./cmd
Then drive it with a gRPC CheckRequest (e.g. via grpcurl) — an unknown host
returns PERMISSION_DENIED / HTTP 403 (fail closed), an unauthenticated API
call returns UNAUTHENTICATED / 401, and an unauthenticated browser
(accept: text/html) returns OK / 302 to the authorize URL with the
original path preserved as attributes.http.path.
Deployment
Images are built multi-arch (linux/amd64 + linux/arm64) with ko
(embedded in skaffold) — no Dockerfile is maintained — pushed to
harbor.animeteamspeak.moe/library/authz-bridge, and applied to the current
kube context:
make image # build+push the multi-arch image (ko via skaffold)
make deploy # build+push, then skaffold apply config/default
skaffold deploy --kube-context portable --images @$(pwd)/bin/skaffold-tags.json
The manifests (CRD, RBAC, Deployment, and the bridge Service on ports 8082/8083)
render from config/ via Kustomize. Because ko places the built binary at
/ko-app/cmd (and sets no entrypoint), the Deployment's command is
[/ko-app/cmd]. Liveness/readiness probes hit the manager's /healthz and
/readyz on :8081; the bridge HTTP surface serves
/oauth2/callback, /oauth2/logout, /healthz, and /readyz on :8082,
and the gRPC Authorization service on :8083 (HTTP/2, transport_api_version: v3).
Logs are structured JSON (--zap-encoder=json), including a check decision
line per request with the decision and reason.
Roll out order matters: verify the bridge denies unknown hosts before any
HTTPRoute points ExternalAuth at it.
Package layout
api/v1alpha1/ AuthzApp CRD schema (markers -> CRD YAML via make manifests)
internal/controller/ AuthzApp reconciler -> config store
internal/configstore/ in-memory per-app config, source of truth
internal/check/ ext_authz gRPC Authorization service + decision engine (HTTP /check kept as test seam)
internal/rp/ OIDC relying party: authorize, callback, logout, RFC 8693 exchange
internal/session/ in-memory per-app session store
internal/secrets/ Kubernetes Secret resolution for clientSecretRef
test/fakekanidm/ the shared fake OIDC provider used across test suites
License
Copyright 2026. Licensed under the Apache License, Version 2.0.