No description
  • Go 92.8%
  • Makefile 5%
  • Shell 2.2%
Find a file
opencode 8b4710b2f4
Some checks failed
Lint / Run on Ubuntu (push) Has been cancelled
E2E Tests / Run on Ubuntu (push) Has been cancelled
Tests / Run on Ubuntu (push) Has been cancelled
authz-bridge (Fix): CEL passthroughExpression for id_token, verify before CEL
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
2026-08-31 22:12:10 -04:00
.devcontainer Bootstrap kubebuilder scaffold with AuthzApp CRD, controller, and config store 2026-08-24 16:47:11 -04:00
.github/workflows Bootstrap kubebuilder scaffold with AuthzApp CRD, controller, and config store 2026-08-24 16:47:11 -04:00
api/v1alpha1 authz-bridge (Fix): CEL passthroughExpression for id_token, verify before CEL 2026-08-31 22:12:10 -04:00
cmd Gate readyz on config store and disable leader election for 1-replica readiness 2026-08-28 13:03:15 -04:00
config authz-bridge (Fix): CEL passthroughExpression for id_token, verify before CEL 2026-08-31 22:12:10 -04:00
examples ext_authz: convert gRPC server to gRPC (HTTP->gRPC) and fix redirect gating 2026-08-28 10:28:34 -04:00
hack Bootstrap kubebuilder scaffold with AuthzApp CRD, controller, and config store 2026-08-24 16:47:11 -04:00
internal authz-bridge (Fix): CEL passthroughExpression for id_token, verify before CEL 2026-08-31 22:12:10 -04:00
test authz-bridge (Fix): id-token passthrough before RFC 8693 exchange 2026-08-31 21:40:39 -04:00
tickets authz-bridge (Fix): id-token passthrough before RFC 8693 exchange 2026-08-31 21:40:39 -04:00
.custom-gcl.yml Bootstrap kubebuilder scaffold with AuthzApp CRD, controller, and config store 2026-08-24 16:47:11 -04:00
.gitignore Bootstrap kubebuilder scaffold with AuthzApp CRD, controller, and config store 2026-08-24 16:47:11 -04:00
.golangci.yml Bootstrap kubebuilder scaffold with AuthzApp CRD, controller, and config store 2026-08-24 16:47:11 -04:00
AGENTS.md Convert to multi-arch ko/skaffold build+deploy; redeploy portable 2026-08-28 14:00:02 -04:00
go.mod authz-bridge (Fix): CEL passthroughExpression for id_token, verify before CEL 2026-08-31 22:12:10 -04:00
go.sum authz-bridge (Fix): CEL passthroughExpression for id_token, verify before CEL 2026-08-31 22:12:10 -04:00
Makefile Makefile deploy: pass skaffold built tag explicitly (ko image sets no entrypoint, and --images @file is not valid) 2026-08-28 14:25:51 -04:00
PROJECT Bootstrap kubebuilder scaffold with AuthzApp CRD, controller, and config store 2026-08-24 16:47:11 -04:00
README.md Convert to multi-arch ko/skaffold build+deploy; redeploy portable 2026-08-28 14:00:02 -04:00
skaffold.yaml Convert to multi-arch ko/skaffold build+deploy; redeploy portable 2026-08-28 14:00:02 -04:00
spec.md ext_authz: convert gRPC server to gRPC (HTTP->gRPC) and fix redirect gating 2026-08-28 10:28:34 -04:00

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's X-Original-URL / X-Forwarded-Uri headers which Envoy does not send — both produced 404s for the first protected request. The gRPC transport removes both defects: it has no path_prefix and the original path arrives as the structured attributes.http.path field.

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.