From 3495e70cbb970d57295b2ebac6e4268d6e60bc09 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Tue, 19 May 2026 12:51:41 +0100 Subject: [PATCH] docs: add oidcgate Tier 1 forward-auth daemon design --- .../specs/2026-05-19-oidcgate-design.md | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-19-oidcgate-design.md diff --git a/docs/superpowers/specs/2026-05-19-oidcgate-design.md b/docs/superpowers/specs/2026-05-19-oidcgate-design.md new file mode 100644 index 0000000..88dcb23 --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-oidcgate-design.md @@ -0,0 +1,173 @@ +# oidcgate — Standalone OIDC Forward-Auth Daemon (Tier 1) + +**Date:** 2026-05-19 +**Status:** Design approved, pending implementation plan +**Scope:** Tier 1 only — forward-auth daemon. No reverse-proxy mode, no TLS termination, no metrics, no multi-tenancy. + +## Goal + +Provide a standalone Go binary that exposes the existing `traefikoidc` OIDC middleware as a forward-auth daemon callable from nginx (`auth_request`), Caddy (`forward_auth`), Traefik (`ForwardAuth`), HAProxy, and Envoy (`ext_authz_http`). The library's public surface is **additive only**: no existing exported function signature changes; one optional `Config` field (`TrustForwardedURI`) and one new read-only accessor on `*TraefikOidc` are added. Existing Traefik plugin users see no behavior change unless they opt in to the new field. + +## Non-Goals + +- Reverse-proxy mode (Tier 2 — separate effort if requested). +- TLS termination inside the daemon. +- Built-in Prometheus metrics. +- Multi-tenant routing (one binary serves one OIDC client config). +- oauth2-proxy CLI/flag compatibility. +- Docker image or goreleaser publishing as part of Tier 1. + +## Architecture + +### File layout + +``` +github.com/lukaszraczylo/traefikoidc (library, unchanged) +├── main.go (existing New/NewWithContext) +├── middleware.go (existing ServeHTTP + ~10 LoC patch) +└── cmd/ + └── oidcgate/ + ├── main.go (entrypoint, flags, signal handling) + ├── config.go (YAML loader + env-var override walker) + ├── server.go (http.ServeMux wiring, listen loop) + ├── endpoints.go (auth/start/callback/logout handlers) + ├── success.go (synthetic success handler used as `next`) + ├── interceptor.go (302→401 response-writer for /oauth2/auth) + ├── health.go (/healthz, /readyz) + ├── config_test.go + ├── endpoints_test.go + └── interceptor_test.go +``` + +### Process model + +Single binary, single `*TraefikOidc` instance built at boot from YAML config, served on one listener port. Health endpoints on the same listener. Graceful shutdown on `SIGINT`/`SIGTERM` via `srv.Shutdown(ctx)` with a 15s deadline, after which context cancellation propagates into the existing goroutine manager. + +## Endpoint Contract + +All four endpoints share the listener. Paths are configurable; defaults shown. + +| Endpoint | Default path | Method | Contract | +|---|---|---|---| +| **Auth probe** | `/oauth2/auth` | GET | Silent. `200 OK` + injected headers on success. `401` on failure (never `302`). Consumed by nginx `auth_request`, Traefik ForwardAuth, Caddy `forward_auth`, Envoy ext_authz_http. | +| **Sign-in** | `/oauth2/start` | GET | Always `302` to IdP `authorize` URL with `state`+`nonce`+PKCE. Reads target URL from `?rd=` query or `X-Forwarded-Uri` header. Hit by the browser after a `401` from `/oauth2/auth`. | +| **Callback** | `config.callbackURL` | GET | IdP `code`+`state` exchange. Existing `auth_flow.go` logic runs unchanged. On success → `302` to original URL. | +| **Logout** | `config.logoutURL` | GET/POST | Existing `logout.go` handler. Terminates session. Honors `oidcEndSessionURL` if configured. | + +### Wiring (how each endpoint delegates) + +All four handlers feed `(*TraefikOidc).ServeHTTP` after rewriting `req.URL.Path`: + +- `/oauth2/callback` → rewrite to `config.callbackURL`, delegate. Middleware path-match at `middleware.go` triggers callback flow. +- `/oauth2/logout` → rewrite to `config.logoutURL`, delegate. Middleware logout path-match at the top of `ServeHTTP` triggers. +- `/oauth2/start` → rewrite `req.URL.Path` to the sentinel `/__oidcgate_protected__`, delegate. Middleware sees an unauthenticated GET on a protected path, emits the `302` to IdP. The redirect flows through naturally. +- `/oauth2/auth` → rewrite `req.URL.Path` to `/__oidcgate_protected__`, wrap `ResponseWriter` with an interceptor (see below), delegate. + +The sentinel path `/__oidcgate_protected__` is chosen because it cannot collide with `callbackURL` / `logoutURL` / `/health*` path matches inside `ServeHTTP` and is not a likely user-configured `excludedURLs` entry. It is internal-only: clients never see it. + +### Synthetic `next` handler + +The middleware calls `t.next.ServeHTTP(rw, req)` at four sites (`middleware.go:174,185,187,592`) when the request is authenticated and should be forwarded. The daemon supplies a `next` that: + +1. Writes `200 OK`. +2. Mirrors any `X-Forwarded-*` and templated headers that the middleware set on `req.Header` (e.g. `X-Forwarded-User` at `middleware.go:101,512`) onto the **response** headers, so proxies can capture them via `auth_request_set` / `authResponseHeaders`. +3. Writes empty body. + +### 302 → 401 interceptor (`/oauth2/auth` only) + +nginx `auth_request` cannot follow `302`s. For the silent endpoint, the daemon wraps the `ResponseWriter` such that: + +- If the middleware writes status `302` (the IdP-redirect branch), the interceptor rewrites it to `401`. +- `Location` header from the swallowed `302` is preserved as `X-Auth-Redirect` on the `401` response (advisory; some proxies may surface it). +- `Set-Cookie` headers (state, PKCE, nonce) are preserved verbatim so the browser carries them into the subsequent `/oauth2/start` request. +- For any non-`302` status, the interceptor is a passthrough. + +`/oauth2/start` does **not** wrap; the middleware's natural `302` flows through to the browser. + +## Configuration + +### Source + +- **File:** `--config /etc/oidcgate/config.yaml` (path overridable via flag). +- **Format:** YAML, unmarshalled into the existing `traefikoidc.Config` struct (`settings.go:39`) via `yaml.v3` (already a dependency in `go.mod`). +- **Migration from Traefik:** copy the `plugin.traefikoidc:` subtree out of `.traefik.yml` and add the daemon-specific top-level keys below. +- **Env-var overrides** (secrets in particular): after YAML unmarshal, walk the config struct. Any scalar string/int/bool field with a non-empty `OIDCGATE_` env-var replaces the YAML value. Nested structs (`Redis`, `SecurityHeaders`, `DynamicClientRegistration`) stay YAML-only. + +### Top-level oidcgate-specific keys + +```yaml +listen: ":8080" # required, listener address +authPath: "/oauth2/auth" # optional, default shown +startPath: "/oauth2/start" # optional, default shown +# all other keys = existing traefikoidc.Config fields +``` + +### Validation + +The existing validation inside `traefikoidc.NewWithContext` (`main.go:97`) runs unchanged. On any returned error, the daemon logs the error and exits non-zero. + +## Library-Side Patches + +Two additive changes, both default-off / read-only: + +1. **`settings.go` + `middleware.go` (~10 LoC):** add `Config.TrustForwardedURI bool` (default `false`). When `true`, the post-login-redirect target captured during the "unauthenticated GET → 302 to IdP" branch is sourced from `req.Header.Get("X-Forwarded-Uri")` if non-empty, instead of from `req.URL`. The daemon sets `TrustForwardedURI = true` at config build time. Default-off preserves current Traefik plugin behavior exactly. + +2. **`main.go` (~5 LoC):** add `func (t *TraefikOidc) Ready() bool` returning `true` once at least one successful OIDC metadata discovery fetch has populated the metadata cache. Read-only; no behavior change for existing consumers. + +## Lifecycle + +``` +parse flags + → load YAML + → apply env-var overrides + → build synthetic success handler + → call traefikoidc.New(ctx, success, cfg, "oidcgate") (validation happens here) + → build mux (auth, start, callback, logout, healthz, readyz) + → http.Server.ListenAndServe on cfg.listen + → wait for SIGINT/SIGTERM + → srv.Shutdown(15s ctx) + → ctx cancel propagates to goroutine manager + → exit 0 +``` + +`/readyz` returns `200` only once `traefikoidc.New` has returned without error **and** the first OIDC metadata discovery fetch has succeeded. Implementation: add a read-only accessor `func (t *TraefikOidc) Ready() bool` that returns `true` once the metadata cache has at least one successful discovery fetch. The daemon's `/readyz` handler calls this and returns `200` / `503` accordingly. + +`/healthz` returns `200` as long as the process is alive. + +## Testing + +- `config_test.go` — YAML round-trip; env-var override precedence; validation pass-through. +- `endpoints_test.go` — `httptest.NewServer`-based scenarios: + - `/oauth2/auth` with no session → `401`. + - `/oauth2/auth` with valid session → `200` + `X-Forwarded-User` mirrored on response. + - `/oauth2/start` → `302` with valid IdP `authorize` URL incl. `state` and PKCE. + - `/oauth2/callback` → completes exchange, sets session, redirects to original URL. + - `/oauth2/logout` → clears session cookie. +- `interceptor_test.go` — middleware-emitted `302` becomes `401`; `Location` → `X-Auth-Redirect`; `Set-Cookie` preserved. +- Reuse existing mock IdP from `enhanced_mocks_test.go`. No new mock infra. + +## Risks & Mitigations + +| Risk | Mitigation | +|---|---| +| Interceptor swallows a legitimate `302` from the middleware that isn't the IdP redirect. | Inspect: only intercept when `Location` matches `t.providerURL`'s authorize endpoint or when it points off-host. Test coverage in `interceptor_test.go`. | +| Library-side patch breaks current Traefik users. | New `TrustForwardedURI` defaults to `false`; existing path untouched when unset. | +| Env-var walker overreaches into nested structs. | Restrict to top-level scalar fields; document explicitly; nested structs stay YAML-only. | +| Path-rewrite trick hits a middleware path-comparison we didn't anticipate. | All four `t.next.ServeHTTP` sites verified at `middleware.go:174,185,187,592`. Endpoint tests exercise each path. | + +## Out of Scope (Tier 2 candidates) + +- Reverse-proxy mode (`httputil.ReverseProxy` as the configured `next`). +- TLS termination (`tls.Config`, ACME). +- Prometheus metrics endpoint. +- Multi-tenant routing. +- oauth2-proxy flag/env compatibility. +- Goreleaser binaries, Docker image, systemd unit. + +## Acceptance Criteria + +1. `go build ./cmd/oidcgate` produces a working binary. +2. Existing test suite (`go test ./...`) still passes — zero regressions in the library. +3. New endpoint tests pass for all four endpoints against the mock IdP. +4. `oidcgate --config example.yaml` boots, serves `/healthz`, performs end-to-end OIDC flow against a real IdP (manual smoke test against e.g. a local Keycloak). +5. README section documents nginx, Caddy, and Traefik wiring examples.