mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-12 04:04:26 +00:00
docs: add oidcgate Tier 1 forward-auth daemon design
This commit is contained in:
@@ -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_<UPPER_SNAKE_CASE_FIELD>` 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.
|
||||
Reference in New Issue
Block a user