diff --git a/docs/superpowers/plans/2026-05-19-oidcgate.md b/docs/superpowers/plans/2026-05-19-oidcgate.md new file mode 100644 index 0000000..07ab47a --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-oidcgate.md @@ -0,0 +1,1823 @@ +# oidcgate Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `cmd/oidcgate`, a standalone Go binary that exposes the existing `traefikoidc` middleware as a forward-auth daemon for nginx / Caddy / Traefik ForwardAuth / HAProxy / Envoy. + +**Architecture:** Single binary, single OIDC client config loaded from YAML (+ env overrides). Four endpoints (`/oauth2/auth`, `/oauth2/start`, ``, ``) plus `/healthz` / `/readyz` on one HTTP listener. All endpoints rewrite `req.URL.Path` and delegate to `(*TraefikOidc).ServeHTTP`. A synthetic `next` handler returns `200` with mirrored headers; a response-writer interceptor converts the middleware's `302`→IdP into `401` for the silent `/oauth2/auth` endpoint so nginx `auth_request` can use it. + +**Tech Stack:** Go 1.24, `gopkg.in/yaml.v3` (already a dep), `net/http`, existing `github.com/lukaszraczylo/traefikoidc` library. Tests use `net/http/httptest` and existing mock IdP from `enhanced_mocks_test.go`. + +**Spec:** `docs/superpowers/specs/2026-05-19-oidcgate-design.md`. + +--- + +## Task 1: Library patch — `TrustForwardedURI` config field + `originalRequestURI` helper + +**Files:** +- Modify: `settings.go` (add field to `Config` struct, around the existing scalar string fields ~`settings.go:39-80`) +- Modify: `types.go` (add field to `TraefikOidc` struct alongside existing scalar bools) +- Modify: `main.go:97` (`NewWithContext` — copy field from `Config` onto `*TraefikOidc`) +- Modify: `auth_flow.go:72,101` (use new helper instead of `req.URL.RequestURI()`) +- Create: `forwarded_uri.go` (the helper) +- Create: `forwarded_uri_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `forwarded_uri_test.go`: + +```go +package traefikoidc + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestOriginalRequestURI_DefaultOff(t *testing.T) { + tr := &TraefikOidc{trustForwardedURI: false} + req := httptest.NewRequest(http.MethodGet, "/protected?x=1", nil) + req.Header.Set("X-Forwarded-Uri", "/spoofed") + if got := tr.originalRequestURI(req); got != "/protected?x=1" { + t.Fatalf("default-off: want /protected?x=1, got %q", got) + } +} + +func TestOriginalRequestURI_TrustEnabled(t *testing.T) { + tr := &TraefikOidc{trustForwardedURI: true} + req := httptest.NewRequest(http.MethodGet, "/protected?x=1", nil) + req.Header.Set("X-Forwarded-Uri", "/real?y=2") + if got := tr.originalRequestURI(req); got != "/real?y=2" { + t.Fatalf("trust-on with header: want /real?y=2, got %q", got) + } +} + +func TestOriginalRequestURI_TrustEnabledNoHeader(t *testing.T) { + tr := &TraefikOidc{trustForwardedURI: true} + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + if got := tr.originalRequestURI(req); got != "/protected" { + t.Fatalf("trust-on no header: want /protected, got %q", got) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd /Users/nvm/Documents/projects/private/traefikoidc/trees/main +go test -run TestOriginalRequestURI ./... +``` + +Expected: FAIL — `originalRequestURI` undefined; `trustForwardedURI` field undefined. + +- [ ] **Step 3: Add the field to `TraefikOidc` struct** + +In `types.go`, find the `type TraefikOidc struct {` block and add (near the other bool flags such as `enablePKCE`, `forceHTTPS`): + +```go + trustForwardedURI bool +``` + +- [ ] **Step 4: Add the field to `Config` struct** + +In `settings.go`, append inside the `type Config struct { ... }` block (before the closing brace), after `ClientAssertionAlg`: + +```go + // TrustForwardedURI, when true, makes the middleware prefer the + // X-Forwarded-Uri request header (set by an upstream reverse proxy) + // over req.URL when capturing the "where was the user going" target + // stored for post-login redirect. Used by the oidcgate standalone + // daemon. Default false preserves the Traefik plugin behavior exactly. + TrustForwardedURI bool `json:"trustForwardedURI,omitempty"` +``` + +- [ ] **Step 5: Copy the field in `NewWithContext`** + +In `main.go`, inside `NewWithContext`, after the block where `t.providerURL = config.ProviderURL` is set (the area around `main.go` lines that assign scalar config fields onto `t`), add: + +```go + t.trustForwardedURI = config.TrustForwardedURI +``` + +(Place it next to other similar single-line `t. = config.` assignments. If you cannot find the exact line, grep: `grep -n "t.providerURL = config" main.go`.) + +- [ ] **Step 6: Write the helper** + +Create `forwarded_uri.go`: + +```go +package traefikoidc + +import "net/http" + +// originalRequestURI returns the request URI that should be used as the +// post-login redirect target. When TrustForwardedURI is enabled and the +// X-Forwarded-Uri header is present, that header wins — this lets the +// oidcgate forward-auth daemon recover the real URL when nginx/Caddy/etc +// proxied a stripped subrequest to /oauth2/auth or /oauth2/start. +func (t *TraefikOidc) originalRequestURI(req *http.Request) string { + if t.trustForwardedURI { + if v := req.Header.Get("X-Forwarded-Uri"); v != "" { + return v + } + } + return req.URL.RequestURI() +} +``` + +- [ ] **Step 7: Run helper tests to verify they pass** + +```bash +go test -run TestOriginalRequestURI ./... +``` + +Expected: PASS (3 tests). + +- [ ] **Step 8: Wire the helper into `auth_flow.go`** + +In `auth_flow.go`, replace the two `req.URL.RequestURI()` call sites at lines 72 and 101 with `t.originalRequestURI(req)`: + +```go +// line 72 (debug log inside defaultInitiateAuthentication) +t.logger.Debugf("Initiating new OIDC authentication flow for request: %s", t.originalRequestURI(req)) + +// line 101 (incoming path stored on session) +t.prepareSessionForAuthentication(session, csrfToken, nonce, codeVerifier, t.originalRequestURI(req)) +``` + +- [ ] **Step 9: Run the full test suite to verify zero regression** + +```bash +go test ./... +``` + +Expected: PASS. If any test fails, fix before commit. + +- [ ] **Step 10: Commit** + +```bash +git add settings.go types.go main.go auth_flow.go forwarded_uri.go forwarded_uri_test.go +git commit -m "feat(lib): add TrustForwardedURI to honor X-Forwarded-Uri for post-login redirect target" +``` + +--- + +## Task 2: Library patch — `(*TraefikOidc).Ready()` accessor + +**Files:** +- Create: `ready.go` +- Create: `ready_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `ready_test.go`: + +```go +package traefikoidc + +import "testing" + +func TestReady_FalseBeforeMetadata(t *testing.T) { + tr := &TraefikOidc{} + if tr.Ready() { + t.Fatal("Ready() should be false before metadata discovery") + } +} + +func TestReady_TrueAfterAuthURLSet(t *testing.T) { + tr := &TraefikOidc{} + tr.metadataMu.Lock() + tr.authURL = "https://idp.example/authorize" + tr.metadataMu.Unlock() + if !tr.Ready() { + t.Fatal("Ready() should be true once authURL is populated") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +go test -run TestReady ./... +``` + +Expected: FAIL — `Ready` undefined. + +- [ ] **Step 3: Write the implementation** + +Create `ready.go`: + +```go +package traefikoidc + +// Ready reports whether the middleware has completed at least one successful +// OIDC provider metadata discovery. Used by external supervisors (e.g. the +// oidcgate /readyz endpoint) to gate traffic until the IdP discovery doc +// has been fetched and the authorization endpoint is known. +func (t *TraefikOidc) Ready() bool { + t.metadataMu.RLock() + defer t.metadataMu.RUnlock() + return t.authURL != "" +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +go test -run TestReady ./... +``` + +Expected: PASS (2 tests). + +- [ ] **Step 5: Run full test suite** + +```bash +go test ./... +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add ready.go ready_test.go +git commit -m "feat(lib): add (*TraefikOidc).Ready() metadata-discovery readiness accessor" +``` + +--- + +## Task 3: oidcgate config loader — YAML → JSON → `Config` + env overrides + +**Files:** +- Create: `cmd/oidcgate/config.go` +- Create: `cmd/oidcgate/config_test.go` + +**Approach:** YAML unmarshal to `map[string]any`, marshal that to JSON, `json.Unmarshal` into `traefikoidc.Config`. This honors the existing `json:` tags on `Config` (~40 fields) without adding `yaml:` tags to the library. Two extra top-level keys (`listen`, `authPath`, `startPath`) live on a wrapper struct. + +- [ ] **Step 1: Write the failing test** + +Create `cmd/oidcgate/config_test.go`: + +```go +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoad_YAMLRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(path, []byte(` +listen: ":9090" +authPath: "/auth" +startPath: "/start" +providerURL: "https://idp.example" +clientID: "abc" +clientSecret: "secret" +sessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +callbackURL: "/oauth2/callback" +logoutURL: "/oauth2/logout" +`), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if cfg.Listen != ":9090" { + t.Errorf("listen: want :9090, got %q", cfg.Listen) + } + if cfg.AuthPath != "/auth" { + t.Errorf("authPath: want /auth, got %q", cfg.AuthPath) + } + if cfg.StartPath != "/start" { + t.Errorf("startPath: want /start, got %q", cfg.StartPath) + } + if cfg.OIDC.ClientID != "abc" { + t.Errorf("clientID: want abc, got %q", cfg.OIDC.ClientID) + } + if cfg.OIDC.ClientSecret != "secret" { + t.Errorf("clientSecret: want secret, got %q", cfg.OIDC.ClientSecret) + } + if !cfg.OIDC.TrustForwardedURI { + t.Errorf("TrustForwardedURI should be forced true by Load") + } +} + +func TestLoad_EnvOverride(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(path, []byte(` +listen: ":8080" +providerURL: "https://idp.example" +clientID: "abc" +clientSecret: "from-file" +sessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +callbackURL: "/oauth2/callback" +logoutURL: "/oauth2/logout" +`), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("OIDCGATE_CLIENT_SECRET", "from-env") + t.Setenv("OIDCGATE_LISTEN", ":9999") + + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if cfg.OIDC.ClientSecret != "from-env" { + t.Errorf("env override (clientSecret): want from-env, got %q", cfg.OIDC.ClientSecret) + } + if cfg.Listen != ":9999" { + t.Errorf("env override (listen): want :9999, got %q", cfg.Listen) + } +} + +func TestLoad_Defaults(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(path, []byte(` +listen: ":8080" +providerURL: "https://idp.example" +clientID: "abc" +clientSecret: "secret" +sessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +callbackURL: "/oauth2/callback" +logoutURL: "/oauth2/logout" +`), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if cfg.AuthPath != "/oauth2/auth" { + t.Errorf("AuthPath default: want /oauth2/auth, got %q", cfg.AuthPath) + } + if cfg.StartPath != "/oauth2/start" { + t.Errorf("StartPath default: want /oauth2/start, got %q", cfg.StartPath) + } +} + +func TestLoad_MissingFile(t *testing.T) { + if _, err := Load("/nonexistent/config.yaml"); err == nil { + t.Fatal("expected error for missing file") + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +go test ./cmd/oidcgate/... +``` + +Expected: FAIL — `Load`, `Config`, etc. undefined; package may not build. + +- [ ] **Step 3: Write the implementation** + +Create `cmd/oidcgate/config.go`: + +```go +package main + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + + "github.com/lukaszraczylo/traefikoidc" + "gopkg.in/yaml.v3" +) + +// Config is the top-level oidcgate configuration. The OIDC subtree maps 1:1 +// onto traefikoidc.Config; the few extra fields configure the daemon itself. +type Config struct { + Listen string `json:"listen"` + AuthPath string `json:"authPath"` + StartPath string `json:"startPath"` + OIDC traefikoidc.Config `json:"-"` +} + +// envScalarOverrides lists Config field names (within OIDC and top-level) +// that may be overridden via OIDCGATE_ environment +// variables. Only scalar strings/ints/bools are supported; nested structs +// (Redis, SecurityHeaders, DynamicClientRegistration) stay YAML-only. +var envScalarFields = []string{ + "Listen", "AuthPath", "StartPath", + "ProviderURL", "ClientID", "ClientSecret", "Audience", + "CallbackURL", "LogoutURL", "PostLogoutRedirectURI", + "SessionEncryptionKey", "CookiePrefix", "CookieDomain", + "LogLevel", "RevocationURL", "OIDCEndSessionURL", + "UserIdentifierClaim", "GroupClaimName", "RoleClaimName", + "ClientAuthMethod", "ClientAssertionPrivateKey", + "ClientAssertionKeyPath", "ClientAssertionKeyID", "ClientAssertionAlg", + "CACertPath", "CACertPEM", +} + +// Load reads YAML from path, applies env-var overrides, fills defaults, +// and forces TrustForwardedURI=true so the library honors X-Forwarded-Uri. +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config: %w", err) + } + + // Pass 1: YAML → generic map. + var raw map[string]any + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("yaml parse: %w", err) + } + + // Split the top-level oidcgate-specific keys away from the OIDC subtree. + listen, _ := raw["listen"].(string) + authPath, _ := raw["authPath"].(string) + startPath, _ := raw["startPath"].(string) + delete(raw, "listen") + delete(raw, "authPath") + delete(raw, "startPath") + + // Pass 2: remaining map → JSON → traefikoidc.Config (uses existing json tags). + jsonBytes, err := json.Marshal(raw) + if err != nil { + return nil, fmt.Errorf("yaml→json: %w", err) + } + var oidcCfg traefikoidc.Config + if err := json.Unmarshal(jsonBytes, &oidcCfg); err != nil { + return nil, fmt.Errorf("oidc config parse: %w", err) + } + + cfg := &Config{ + Listen: listen, + AuthPath: authPath, + StartPath: startPath, + OIDC: oidcCfg, + } + + applyEnvOverrides(cfg) + applyDefaults(cfg) + + // Force standalone semantics: trust X-Forwarded-Uri. + cfg.OIDC.TrustForwardedURI = true + return cfg, nil +} + +// applyEnvOverrides walks the allow-listed scalar fields and replaces any +// non-empty OIDCGATE_ env var. Field name "ClientID" +// becomes "OIDCGATE_CLIENT_ID"; "SessionEncryptionKey" becomes +// "OIDCGATE_SESSION_ENCRYPTION_KEY". +func applyEnvOverrides(cfg *Config) { + for _, field := range envScalarFields { + env := os.Getenv("OIDCGATE_" + camelToSnakeUpper(field)) + if env == "" { + continue + } + setScalarField(cfg, field, env) + } +} + +func setScalarField(cfg *Config, field, value string) { + switch field { + case "Listen": + cfg.Listen = value + case "AuthPath": + cfg.AuthPath = value + case "StartPath": + cfg.StartPath = value + case "ProviderURL": + cfg.OIDC.ProviderURL = value + case "ClientID": + cfg.OIDC.ClientID = value + case "ClientSecret": + cfg.OIDC.ClientSecret = value + case "Audience": + cfg.OIDC.Audience = value + case "CallbackURL": + cfg.OIDC.CallbackURL = value + case "LogoutURL": + cfg.OIDC.LogoutURL = value + case "PostLogoutRedirectURI": + cfg.OIDC.PostLogoutRedirectURI = value + case "SessionEncryptionKey": + cfg.OIDC.SessionEncryptionKey = value + case "CookiePrefix": + cfg.OIDC.CookiePrefix = value + case "CookieDomain": + cfg.OIDC.CookieDomain = value + case "LogLevel": + cfg.OIDC.LogLevel = value + case "RevocationURL": + cfg.OIDC.RevocationURL = value + case "OIDCEndSessionURL": + cfg.OIDC.OIDCEndSessionURL = value + case "UserIdentifierClaim": + cfg.OIDC.UserIdentifierClaim = value + case "GroupClaimName": + cfg.OIDC.GroupClaimName = value + case "RoleClaimName": + cfg.OIDC.RoleClaimName = value + case "ClientAuthMethod": + cfg.OIDC.ClientAuthMethod = value + case "ClientAssertionPrivateKey": + cfg.OIDC.ClientAssertionPrivateKey = value + case "ClientAssertionKeyPath": + cfg.OIDC.ClientAssertionKeyPath = value + case "ClientAssertionKeyID": + cfg.OIDC.ClientAssertionKeyID = value + case "ClientAssertionAlg": + cfg.OIDC.ClientAssertionAlg = value + case "CACertPath": + cfg.OIDC.CACertPath = value + case "CACertPEM": + cfg.OIDC.CACertPEM = value + } + // strconv reserved for future int/bool fields when added to the allow-list. + _ = strconv.Atoi +} + +func applyDefaults(cfg *Config) { + if cfg.AuthPath == "" { + cfg.AuthPath = "/oauth2/auth" + } + if cfg.StartPath == "" { + cfg.StartPath = "/oauth2/start" + } +} + +// camelToSnakeUpper turns "ClientSecret" into "CLIENT_SECRET", +// "SessionEncryptionKey" into "SESSION_ENCRYPTION_KEY", etc. +// Multi-letter acronyms keep their grouping: "OIDCEndSessionURL" → +// "OIDC_END_SESSION_URL", "CACertPEM" → "CA_CERT_PEM". +func camelToSnakeUpper(s string) string { + var b strings.Builder + for i, r := range s { + if i > 0 && isUpper(r) { + prev := rune(s[i-1]) + next := rune(0) + if i+1 < len(s) { + next = rune(s[i+1]) + } + if !isUpper(prev) || (next != 0 && !isUpper(next) && next != 0) { + b.WriteByte('_') + } + } + if r >= 'a' && r <= 'z' { + r -= 32 + } + b.WriteRune(r) + } + return b.String() +} + +func isUpper(r rune) bool { return r >= 'A' && r <= 'Z' } +``` + +- [ ] **Step 4: Initialize the cmd/oidcgate package** + +The package needs a main entrypoint to compile (tests need the package to build). Create a stub `cmd/oidcgate/main.go`: + +```go +package main + +func main() {} +``` + +This stub is replaced in Task 9. + +- [ ] **Step 5: Run config tests** + +```bash +go test ./cmd/oidcgate/... +``` + +Expected: PASS (4 config tests). + +- [ ] **Step 6: Commit** + +```bash +git add cmd/oidcgate/config.go cmd/oidcgate/config_test.go cmd/oidcgate/main.go +git commit -m "feat(oidcgate): YAML config loader with env-var overrides" +``` + +--- + +## Task 4: oidcgate synthetic success handler + +**Files:** +- Create: `cmd/oidcgate/success.go` +- Create: `cmd/oidcgate/success_test.go` + +**Purpose:** The middleware calls `t.next.ServeHTTP(rw, req)` at four sites after stamping `X-Forwarded-User` and templated headers onto `req.Header`. The forward-auth contract needs those headers on the **response** so nginx `auth_request_set` / Caddy `copy_headers` can pull them out. This handler mirrors `req.Header` → response and writes `200`. + +- [ ] **Step 1: Write the failing test** + +Create `cmd/oidcgate/success_test.go`: + +```go +package main + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestSuccessHandler_Writes200(t *testing.T) { + h := newSuccessHandler() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/x", nil) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status: want 200, got %d", rec.Code) + } +} + +func TestSuccessHandler_MirrorsForwardedHeaders(t *testing.T) { + h := newSuccessHandler() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("X-Forwarded-User", "alice@example.com") + req.Header.Set("X-Forwarded-Email", "alice@example.com") + req.Header.Set("X-Custom-Templated", "value") + req.Header.Set("Authorization", "Bearer token-from-template") + req.Header.Set("Cookie", "session=should-NOT-mirror") + + h.ServeHTTP(rec, req) + + if got := rec.Header().Get("X-Forwarded-User"); got != "alice@example.com" { + t.Errorf("X-Forwarded-User: want mirrored, got %q", got) + } + if got := rec.Header().Get("X-Forwarded-Email"); got != "alice@example.com" { + t.Errorf("X-Forwarded-Email: want mirrored, got %q", got) + } + if got := rec.Header().Get("X-Custom-Templated"); got != "value" { + t.Errorf("X-Custom-Templated: want mirrored (X- prefix), got %q", got) + } + if got := rec.Header().Get("Authorization"); got != "Bearer token-from-template" { + t.Errorf("Authorization: want mirrored (templated bearer), got %q", got) + } + if got := rec.Header().Get("Cookie"); got != "" { + t.Errorf("Cookie must NOT be mirrored, got %q", got) + } +} + +func TestSuccessHandler_EmptyBody(t *testing.T) { + h := newSuccessHandler() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/x", nil) + h.ServeHTTP(rec, req) + if body := strings.TrimSpace(rec.Body.String()); body != "" { + t.Fatalf("body: want empty, got %q", body) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +go test ./cmd/oidcgate/... -run TestSuccessHandler +``` + +Expected: FAIL — `newSuccessHandler` undefined. + +- [ ] **Step 3: Write the implementation** + +Create `cmd/oidcgate/success.go`: + +```go +package main + +import ( + "net/http" + "strings" +) + +// mirrorAllowedHeaders is the set of NON-X-prefixed request headers that the +// success handler copies onto the response. The traefikoidc middleware sets +// "Authorization: Bearer ..." via the templated-header feature when operators +// configure it, and proxies need that to flow upstream. +var mirrorAllowedHeaders = map[string]struct{}{ + "Authorization": {}, +} + +// successHandler is the http.Handler installed as the middleware's `next`. +// When the middleware reaches this handler the request is authenticated; we +// mirror the X-* (and a small allow-list of non-X-*) headers the middleware +// stamped onto req.Header back onto the response so upstream proxies can +// capture them via auth_request_set / authResponseHeaders / copy_headers, +// then write 200 with an empty body. +func newSuccessHandler() http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + for name, values := range req.Header { + if !shouldMirror(name) { + continue + } + for _, v := range values { + rw.Header().Add(name, v) + } + } + rw.WriteHeader(http.StatusOK) + }) +} + +func shouldMirror(name string) bool { + if strings.HasPrefix(name, "X-") || strings.HasPrefix(name, "x-") { + return true + } + canonical := http.CanonicalHeaderKey(name) + _, ok := mirrorAllowedHeaders[canonical] + return ok +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +go test ./cmd/oidcgate/... -run TestSuccessHandler +``` + +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add cmd/oidcgate/success.go cmd/oidcgate/success_test.go +git commit -m "feat(oidcgate): synthetic success handler mirrors X-* headers to response" +``` + +--- + +## Task 5: oidcgate 302→401 response-writer interceptor + +**Files:** +- Create: `cmd/oidcgate/interceptor.go` +- Create: `cmd/oidcgate/interceptor_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `cmd/oidcgate/interceptor_test.go`: + +```go +package main + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestInterceptor_302BecomesNot401(t *testing.T) { + rec := httptest.NewRecorder() + w := newAuthInterceptor(rec) + + w.Header().Set("Location", "https://idp.example/authorize?state=abc") + w.Header().Add("Set-Cookie", "_oidc_state=abc; Path=/; HttpOnly") + w.Header().Add("Set-Cookie", "_oidc_pkce=xyz; Path=/; HttpOnly") + w.WriteHeader(http.StatusFound) + _, _ = w.Write([]byte("ignored body")) + + w.Finalize() + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status: want 401, got %d", rec.Code) + } + if got := rec.Header().Get("X-Auth-Redirect"); got != "https://idp.example/authorize?state=abc" { + t.Errorf("X-Auth-Redirect: want preserved Location, got %q", got) + } + if got := rec.Header().Get("Location"); got != "" { + t.Errorf("Location must be stripped on 401, got %q", got) + } + cookies := rec.Header().Values("Set-Cookie") + if len(cookies) != 2 { + t.Fatalf("Set-Cookie count: want 2, got %d (%v)", len(cookies), cookies) + } + if body := strings.TrimSpace(rec.Body.String()); body != "" { + t.Errorf("body must be empty on 401, got %q", body) + } +} + +func TestInterceptor_NonRedirectPassthrough(t *testing.T) { + rec := httptest.NewRecorder() + w := newAuthInterceptor(rec) + + w.Header().Set("X-Forwarded-User", "alice") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + + w.Finalize() + + if rec.Code != http.StatusOK { + t.Fatalf("status: want 200, got %d", rec.Code) + } + if got := rec.Header().Get("X-Forwarded-User"); got != "alice" { + t.Errorf("X-Forwarded-User: want preserved, got %q", got) + } + if !strings.Contains(rec.Body.String(), "ok") { + t.Errorf("body: want 'ok' preserved, got %q", rec.Body.String()) + } +} + +func TestInterceptor_303SeeOtherAlsoIntercepted(t *testing.T) { + rec := httptest.NewRecorder() + w := newAuthInterceptor(rec) + w.Header().Set("Location", "/elsewhere") + w.WriteHeader(http.StatusSeeOther) + w.Finalize() + if rec.Code != http.StatusUnauthorized { + t.Fatalf("303 should be intercepted to 401, got %d", rec.Code) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +go test ./cmd/oidcgate/... -run TestInterceptor +``` + +Expected: FAIL — `newAuthInterceptor` undefined. + +- [ ] **Step 3: Write the implementation** + +Create `cmd/oidcgate/interceptor.go`: + +```go +package main + +import "net/http" + +// authInterceptor wraps a ResponseWriter for the /oauth2/auth endpoint. +// The traefikoidc middleware emits an HTTP 302 to the IdP authorize URL +// when a request is unauthenticated, but nginx auth_request and similar +// silent-probe contracts cannot follow redirects. authInterceptor buffers +// the header/body and, at Finalize() time: +// +// - if status was 302 or 303 (redirect class we care about), rewrites +// it to 401, moves the original Location header to X-Auth-Redirect +// (advisory), strips Location, preserves Set-Cookie headers (state, +// PKCE, nonce — the browser will carry them into the next request), +// and writes an empty body. +// - otherwise: passes through verbatim. +type authInterceptor struct { + inner http.ResponseWriter + headers http.Header + status int + body []byte + wroteHeader bool +} + +func newAuthInterceptor(inner http.ResponseWriter) *authInterceptor { + return &authInterceptor{ + inner: inner, + headers: http.Header{}, + status: http.StatusOK, + } +} + +func (w *authInterceptor) Header() http.Header { return w.headers } + +func (w *authInterceptor) WriteHeader(status int) { + if w.wroteHeader { + return + } + w.status = status + w.wroteHeader = true +} + +func (w *authInterceptor) Write(b []byte) (int, error) { + if !w.wroteHeader { + w.WriteHeader(http.StatusOK) + } + w.body = append(w.body, b...) + return len(b), nil +} + +// Finalize flushes the buffered response, applying the 302/303 → 401 rewrite. +// Must be called exactly once after the wrapped handler returns. +func (w *authInterceptor) Finalize() { + switch w.status { + case http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect: + // Move Location → X-Auth-Redirect, strip Location, force 401, drop body. + if loc := w.headers.Get("Location"); loc != "" { + w.headers.Set("X-Auth-Redirect", loc) + w.headers.Del("Location") + } + copyHeaders(w.inner.Header(), w.headers) + w.inner.WriteHeader(http.StatusUnauthorized) + return + } + copyHeaders(w.inner.Header(), w.headers) + w.inner.WriteHeader(w.status) + if len(w.body) > 0 { + _, _ = w.inner.Write(w.body) + } +} + +func copyHeaders(dst, src http.Header) { + for k, vs := range src { + for _, v := range vs { + dst.Add(k, v) + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +go test ./cmd/oidcgate/... -run TestInterceptor +``` + +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add cmd/oidcgate/interceptor.go cmd/oidcgate/interceptor_test.go +git commit -m "feat(oidcgate): response-writer interceptor converts 302→401 for /oauth2/auth" +``` + +--- + +## Task 6: oidcgate endpoints — `/oauth2/auth`, `/oauth2/start`, callback, logout + +**Files:** +- Create: `cmd/oidcgate/endpoints.go` +- Create: `cmd/oidcgate/endpoints_test.go` + +**Sentinel path:** `/__oidcgate_protected__` — never matches `callbackURL`, `logoutURL`, `/health*`, or typical `excludedURLs`, so the middleware treats requests under it as "protected, requires auth". + +- [ ] **Step 1: Write the failing test** + +Create `cmd/oidcgate/endpoints_test.go`: + +```go +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// stubMiddleware lets us test endpoint wiring without spinning up a full +// traefikoidc instance. Each test injects the behavior it wants. +type stubMiddleware struct { + calls []stubCall + fn func(rw http.ResponseWriter, req *http.Request) +} + +type stubCall struct { + path string + header http.Header +} + +func (s *stubMiddleware) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + s.calls = append(s.calls, stubCall{path: req.URL.Path, header: req.Header.Clone()}) + if s.fn != nil { + s.fn(rw, req) + } +} + +func TestAuth_RewritesToSentinel_AndConverts302To401(t *testing.T) { + stub := &stubMiddleware{ + fn: func(rw http.ResponseWriter, req *http.Request) { + rw.Header().Set("Location", "https://idp.example/authorize?state=abc") + rw.Header().Add("Set-Cookie", "_oidc_state=abc; Path=/") + rw.WriteHeader(http.StatusFound) + }, + } + h := newAuthHandler(stub) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/oauth2/auth", nil) + req.Header.Set("X-Forwarded-Uri", "/protected/page") + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status: want 401, got %d", rec.Code) + } + if len(stub.calls) != 1 || stub.calls[0].path != sentinelPath { + t.Fatalf("middleware path: want %q, got %v", sentinelPath, stub.calls) + } + if rec.Header().Get("X-Auth-Redirect") == "" { + t.Error("X-Auth-Redirect should carry Location") + } +} + +func TestAuth_AuthenticatedReturnsHeadersAnd200(t *testing.T) { + stub := &stubMiddleware{ + fn: func(rw http.ResponseWriter, req *http.Request) { + // Middleware would stamp X-Forwarded-User on req then call next. + req.Header.Set("X-Forwarded-User", "alice") + newSuccessHandler().ServeHTTP(rw, req) + }, + } + h := newAuthHandler(stub) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/oauth2/auth", nil) + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: want 200, got %d", rec.Code) + } + if got := rec.Header().Get("X-Forwarded-User"); got != "alice" { + t.Errorf("X-Forwarded-User mirrored: want alice, got %q", got) + } +} + +func TestStart_DelegatesWithSentinel_NoInterception(t *testing.T) { + stub := &stubMiddleware{ + fn: func(rw http.ResponseWriter, req *http.Request) { + rw.Header().Set("Location", "https://idp.example/authorize") + rw.WriteHeader(http.StatusFound) + }, + } + h := newStartHandler(stub) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/oauth2/start?rd=/back", nil) + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusFound { + t.Fatalf("start: 302 must flow through, got %d", rec.Code) + } + if stub.calls[0].path != sentinelPath { + t.Fatalf("start path rewrite: want %q, got %q", sentinelPath, stub.calls[0].path) + } +} + +func TestStart_ForwardsRdAsXForwardedURI(t *testing.T) { + stub := &stubMiddleware{ + fn: func(rw http.ResponseWriter, req *http.Request) { rw.WriteHeader(http.StatusFound) }, + } + h := newStartHandler(stub) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/oauth2/start?rd=/back/here", nil) + h.ServeHTTP(rec, req) + if got := stub.calls[0].header.Get("X-Forwarded-Uri"); got != "/back/here" { + t.Fatalf("?rd should become X-Forwarded-Uri: want /back/here, got %q", got) + } +} + +func TestCallback_RewritesToConfiguredCallbackURL(t *testing.T) { + var seenPath, seenQuery string + stub := &stubMiddleware{ + fn: func(rw http.ResponseWriter, req *http.Request) { + seenPath = req.URL.Path + seenQuery = req.URL.RawQuery + rw.WriteHeader(http.StatusOK) + }, + } + h := newCallbackHandler(stub, "/oauth2/callback") + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/oauth2/callback?code=abc&state=xyz", nil) + h.ServeHTTP(rec, req) + + if seenPath != "/oauth2/callback" { + t.Fatalf("callback path: want /oauth2/callback, got %q", seenPath) + } + if seenQuery != "code=abc&state=xyz" { + t.Fatalf("callback query must survive rewrite: want code=abc&state=xyz, got %q", seenQuery) + } +} + +func TestLogout_RewritesToConfiguredLogoutURL(t *testing.T) { + var seenPath string + stub := &stubMiddleware{ + fn: func(rw http.ResponseWriter, req *http.Request) { + seenPath = req.URL.Path + rw.WriteHeader(http.StatusOK) + }, + } + h := newLogoutHandler(stub, "/oauth2/logout") + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/oauth2/logout", nil) + h.ServeHTTP(rec, req) + + if seenPath != "/oauth2/logout" { + t.Fatalf("logout path: want /oauth2/logout, got %q", seenPath) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +go test ./cmd/oidcgate/... -run 'TestAuth_|TestStart_|TestCallback_|TestLogout_' +``` + +Expected: FAIL — `newAuthHandler`, `newStartHandler`, `newCallbackHandler`, `newLogoutHandler`, `sentinelPath` undefined. + +- [ ] **Step 3: Write the implementation** + +Create `cmd/oidcgate/endpoints.go`: + +```go +package main + +import "net/http" + +// sentinelPath is the synthetic request path used when delegating /oauth2/auth +// and /oauth2/start into the traefikoidc middleware. It must NOT collide with +// callbackURL, logoutURL, /health*, or any plausible excludedURLs entry — +// the underscores and double-prefixing make accidental matches near-impossible. +const sentinelPath = "/__oidcgate_protected__" + +// newAuthHandler builds the /oauth2/auth (silent probe) handler. +// Rewrites the request path to sentinelPath, wraps the ResponseWriter to +// convert the middleware's 302→IdP into 401, and delegates. +func newAuthHandler(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + ic := newAuthInterceptor(rw) + r2 := cloneAndRewrite(req, sentinelPath) + next.ServeHTTP(ic, r2) + ic.Finalize() + }) +} + +// newStartHandler builds the /oauth2/start (visible sign-in) handler. +// Rewrites the path to sentinelPath, forwards any ?rd= query as +// X-Forwarded-Uri so the middleware (with TrustForwardedURI=true) captures +// the right post-login redirect target, then delegates. The middleware's +// natural 302→IdP flows through unchanged. +func newStartHandler(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + r2 := cloneAndRewrite(req, sentinelPath) + if rd := req.URL.Query().Get("rd"); rd != "" && r2.Header.Get("X-Forwarded-Uri") == "" { + r2.Header.Set("X-Forwarded-Uri", rd) + } + next.ServeHTTP(rw, r2) + }) +} + +// newCallbackHandler builds the IdP callback endpoint. +// Rewrites the request path to the configured callbackURL so the middleware's +// path-match at the top of ServeHTTP triggers the callback flow. +func newCallbackHandler(next http.Handler, callbackURL string) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + r2 := cloneAndRewrite(req, callbackURL) + next.ServeHTTP(rw, r2) + }) +} + +// newLogoutHandler builds the logout endpoint. +// Rewrites the request path to the configured logoutURL so the middleware's +// path-match at the top of ServeHTTP triggers the logout flow. +func newLogoutHandler(next http.Handler, logoutURL string) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + r2 := cloneAndRewrite(req, logoutURL) + next.ServeHTTP(rw, r2) + }) +} + +// cloneAndRewrite returns a shallow clone of req with URL.Path set to newPath. +// The query string is preserved verbatim — middleware logic for code/state +// extraction reads URL.Query() which still works. +func cloneAndRewrite(req *http.Request, newPath string) *http.Request { + r2 := req.Clone(req.Context()) + u := *req.URL + u.Path = newPath + r2.URL = &u + return r2 +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +go test ./cmd/oidcgate/... -run 'TestAuth_|TestStart_|TestCallback_|TestLogout_' +``` + +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +git add cmd/oidcgate/endpoints.go cmd/oidcgate/endpoints_test.go +git commit -m "feat(oidcgate): auth/start/callback/logout endpoint handlers" +``` + +--- + +## Task 7: oidcgate health endpoints + +**Files:** +- Create: `cmd/oidcgate/health.go` +- Create: `cmd/oidcgate/health_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `cmd/oidcgate/health_test.go`: + +```go +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +type readyStub struct{ ready bool } + +func (r *readyStub) Ready() bool { return r.ready } + +func TestHealthz_Always200(t *testing.T) { + h := newHealthzHandler() + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("healthz: want 200, got %d", rec.Code) + } +} + +func TestReadyz_503BeforeDiscovery(t *testing.T) { + h := newReadyzHandler(&readyStub{ready: false}) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil)) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("readyz pre-discovery: want 503, got %d", rec.Code) + } +} + +func TestReadyz_200AfterDiscovery(t *testing.T) { + h := newReadyzHandler(&readyStub{ready: true}) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("readyz post-discovery: want 200, got %d", rec.Code) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +go test ./cmd/oidcgate/... -run 'TestHealthz|TestReadyz' +``` + +Expected: FAIL — `newHealthzHandler`, `newReadyzHandler` undefined. + +- [ ] **Step 3: Write the implementation** + +Create `cmd/oidcgate/health.go`: + +```go +package main + +import "net/http" + +// readyReporter is satisfied by *traefikoidc.TraefikOidc via its Ready() method. +type readyReporter interface { + Ready() bool +} + +func newHealthzHandler() http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { + rw.WriteHeader(http.StatusOK) + }) +} + +func newReadyzHandler(r readyReporter) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { + if r.Ready() { + rw.WriteHeader(http.StatusOK) + return + } + rw.WriteHeader(http.StatusServiceUnavailable) + }) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +go test ./cmd/oidcgate/... -run 'TestHealthz|TestReadyz' +``` + +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add cmd/oidcgate/health.go cmd/oidcgate/health_test.go +git commit -m "feat(oidcgate): /healthz and /readyz endpoints" +``` + +--- + +## Task 8: oidcgate mux wiring + server + +**Files:** +- Create: `cmd/oidcgate/server.go` +- Create: `cmd/oidcgate/server_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `cmd/oidcgate/server_test.go`: + +```go +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestMux_RoutesAllEndpoints(t *testing.T) { + stub := &stubMiddleware{ + fn: func(rw http.ResponseWriter, _ *http.Request) { rw.WriteHeader(http.StatusOK) }, + } + mux := buildMux(&Config{ + Listen: ":0", + AuthPath: "/oauth2/auth", + StartPath: "/oauth2/start", + OIDC: traefikoidcConfigStub{ + callbackURL: "/oauth2/callback", + logoutURL: "/oauth2/logout", + }.AsOIDC(), + }, stub, &readyStub{ready: true}) + + cases := []struct { + path string + method string + want int + }{ + {"/healthz", http.MethodGet, http.StatusOK}, + {"/readyz", http.MethodGet, http.StatusOK}, + {"/oauth2/auth", http.MethodGet, http.StatusOK}, + {"/oauth2/start", http.MethodGet, http.StatusOK}, + {"/oauth2/callback", http.MethodGet, http.StatusOK}, + {"/oauth2/logout", http.MethodPost, http.StatusOK}, + } + for _, c := range cases { + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest(c.method, c.path, nil)) + if rec.Code != c.want { + t.Errorf("%s %s: want %d, got %d", c.method, c.path, c.want, rec.Code) + } + } +} +``` + +Also append a small helper to `endpoints_test.go` (the `stubMiddleware` lives there already) — we need a tiny helper to build a `traefikoidc.Config` without importing the package's internals from the test. Add to a new test helper file `cmd/oidcgate/helpers_test.go`: + +```go +package main + +import "github.com/lukaszraczylo/traefikoidc" + +type traefikoidcConfigStub struct { + callbackURL string + logoutURL string +} + +func (s traefikoidcConfigStub) AsOIDC() traefikoidc.Config { + return traefikoidc.Config{ + CallbackURL: s.callbackURL, + LogoutURL: s.logoutURL, + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +go test ./cmd/oidcgate/... -run TestMux +``` + +Expected: FAIL — `buildMux` undefined. + +- [ ] **Step 3: Write the implementation** + +Create `cmd/oidcgate/server.go`: + +```go +package main + +import ( + "context" + "net/http" + "time" +) + +// buildMux wires all six routes onto a single ServeMux: +// /healthz, /readyz, AuthPath, StartPath, OIDC.CallbackURL, OIDC.LogoutURL. +// The same `middleware` instance is delegated to by all four OIDC routes; +// the synthetic success handler is wired into the middleware at construction +// time (in main.go) so it doesn't appear here. +func buildMux(cfg *Config, middleware http.Handler, ready readyReporter) *http.ServeMux { + mux := http.NewServeMux() + mux.Handle("/healthz", newHealthzHandler()) + mux.Handle("/readyz", newReadyzHandler(ready)) + mux.Handle(cfg.AuthPath, newAuthHandler(middleware)) + mux.Handle(cfg.StartPath, newStartHandler(middleware)) + mux.Handle(cfg.OIDC.CallbackURL, newCallbackHandler(middleware, cfg.OIDC.CallbackURL)) + mux.Handle(cfg.OIDC.LogoutURL, newLogoutHandler(middleware, cfg.OIDC.LogoutURL)) + return mux +} + +// buildServer wraps the mux in an http.Server with sensible timeouts. +func buildServer(cfg *Config, mux http.Handler) *http.Server { + return &http.Server{ + Addr: cfg.Listen, + Handler: mux, + ReadHeaderTimeout: 10 * time.Second, + IdleTimeout: 120 * time.Second, + } +} + +// shutdown gracefully stops the server with a 15s deadline. +func shutdown(srv *http.Server) error { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + return srv.Shutdown(ctx) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +go test ./cmd/oidcgate/... +``` + +Expected: PASS (all package tests). + +- [ ] **Step 5: Commit** + +```bash +git add cmd/oidcgate/server.go cmd/oidcgate/server_test.go cmd/oidcgate/helpers_test.go +git commit -m "feat(oidcgate): mux wiring + http.Server with graceful shutdown" +``` + +--- + +## Task 9: oidcgate main entrypoint + +**Files:** +- Modify: `cmd/oidcgate/main.go` (replace Task 3 stub) + +- [ ] **Step 1: Write the implementation** + +Replace `cmd/oidcgate/main.go` contents entirely: + +```go +package main + +import ( + "context" + "errors" + "flag" + "log" + "net/http" + "os" + "os/signal" + "syscall" + + "github.com/lukaszraczylo/traefikoidc" +) + +func main() { + configPath := flag.String("config", "/etc/oidcgate/config.yaml", "Path to YAML config file") + flag.Parse() + + cfg, err := Load(*configPath) + if err != nil { + log.Fatalf("oidcgate: load config: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + success := newSuccessHandler() + middleware, err := traefikoidc.NewWithContext(ctx, &cfg.OIDC, success, "oidcgate") + if err != nil { + log.Fatalf("oidcgate: build middleware: %v", err) + } + + mux := buildMux(cfg, middleware, middleware) + srv := buildServer(cfg, mux) + + go func() { + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + <-sigs + log.Println("oidcgate: shutdown signal received") + if err := shutdown(srv); err != nil { + log.Printf("oidcgate: shutdown error: %v", err) + } + cancel() + }() + + log.Printf("oidcgate: listening on %s", cfg.Listen) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("oidcgate: serve: %v", err) + } + log.Println("oidcgate: stopped") +} +``` + +- [ ] **Step 2: Verify build** + +```bash +go build ./cmd/oidcgate +``` + +Expected: builds without error, produces `oidcgate` binary in the working directory. + +- [ ] **Step 3: Smoke test — binary boots, /healthz responds** + +Write a minimal smoke config to a temp file and run: + +```bash +TMP=$(mktemp -d) +cat > "$TMP/config.yaml" <<'YAML' +listen: "127.0.0.1:18080" +providerURL: "https://accounts.google.com" +clientID: "smoke" +clientSecret: "smoke" +sessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +callbackURL: "/oauth2/callback" +logoutURL: "/oauth2/logout" +YAML +./oidcgate --config "$TMP/config.yaml" & +PID=$! +sleep 1 +curl -sf http://127.0.0.1:18080/healthz && echo "healthz OK" +kill -TERM $PID +wait $PID 2>/dev/null +rm -rf "$TMP" +``` + +Expected: `healthz OK` printed, process exits cleanly. + +- [ ] **Step 4: Run full test suite** + +```bash +go test ./... +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add cmd/oidcgate/main.go +git commit -m "feat(oidcgate): main entrypoint with graceful shutdown" +``` + +--- + +## Task 10: Documentation — nginx / Caddy / Traefik wiring + +**Files:** +- Create: `docs/OIDCGATE.md` +- Modify: `README.md` (add a short section linking to OIDCGATE.md) +- Create: `examples/oidcgate.yaml` + +- [ ] **Step 1: Write the oidcgate doc** + +Create `docs/OIDCGATE.md`: + +````markdown +# oidcgate — standalone OIDC forward-auth daemon + +`oidcgate` is a single binary that exposes the same OIDC middleware that +powers the Traefik plugin as a forward-auth daemon for nginx, Caddy, +Traefik ForwardAuth, HAProxy, and Envoy ext_authz_http. + +## Build + +```bash +go build -o oidcgate ./cmd/oidcgate +``` + +## Run + +```bash +./oidcgate --config /etc/oidcgate/config.yaml +``` + +## Config + +YAML, mirroring the existing Traefik plugin schema with three extra keys: + +```yaml +listen: ":8080" +authPath: "/oauth2/auth" # default; nginx auth_request subrequest target +startPath: "/oauth2/start" # default; visible sign-in endpoint +providerURL: "https://accounts.google.com" +clientID: "your-client-id" +clientSecret: "your-client-secret" +sessionEncryptionKey: "64-hex-bytes" +callbackURL: "/oauth2/callback" +logoutURL: "/oauth2/logout" +# ... any other traefikoidc Config field works here verbatim +``` + +Secrets can be overridden via environment variables: +`OIDCGATE_CLIENT_SECRET`, `OIDCGATE_SESSION_ENCRYPTION_KEY`, +`OIDCGATE_CLIENT_ID`, etc. + +## Endpoints + +| Path | Method | Purpose | +|---|---|---| +| `/oauth2/auth` | GET | Silent probe — `200` if authenticated, `401` if not | +| `/oauth2/start` | GET | Visible sign-in — `302` to IdP, accepts `?rd=` for return target | +| `/oauth2/callback` | GET | IdP callback | +| `/oauth2/logout` | GET/POST | Session terminate | +| `/healthz` | GET | Liveness — `200` while process is alive | +| `/readyz` | GET | Readiness — `200` after first metadata discovery, else `503` | + +## nginx + +```nginx +location = /oauth2/auth { + internal; + proxy_pass http://oidcgate:8080; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Forwarded-Uri $request_uri; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; +} +location @oidc_signin { + return 302 /oauth2/start?rd=$scheme://$host$request_uri; +} +location /oauth2/ { + proxy_pass http://oidcgate:8080; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; +} +location / { + auth_request /oauth2/auth; + error_page 401 = @oidc_signin; + auth_request_set $user $upstream_http_x_forwarded_user; + auth_request_set $email $upstream_http_x_forwarded_email; + proxy_set_header X-Forwarded-User $user; + proxy_set_header X-Forwarded-Email $email; + proxy_pass http://backend; +} +``` + +## Caddy + +``` +example.com { + forward_auth oidcgate:8080 { + uri /oauth2/auth + copy_headers X-Forwarded-User X-Forwarded-Email + } + handle /oauth2/* { + reverse_proxy oidcgate:8080 + } + reverse_proxy backend:3000 +} +``` + +## Traefik ForwardAuth + +```yaml +http: + middlewares: + oidcgate: + forwardAuth: + address: "http://oidcgate:8080/oauth2/auth" + authResponseHeaders: + - X-Forwarded-User + - X-Forwarded-Email +``` +```` + +- [ ] **Step 2: Write the example config** + +Create `examples/oidcgate.yaml`: + +```yaml +# Minimal oidcgate config. See docs/OIDCGATE.md for full reference. +listen: ":8080" +authPath: "/oauth2/auth" +startPath: "/oauth2/start" + +providerURL: "https://accounts.google.com" +clientID: "REPLACE_ME.apps.googleusercontent.com" +clientSecret: "REPLACE_ME" # OR set OIDCGATE_CLIENT_SECRET +sessionEncryptionKey: "REPLACE_WITH_64_HEX_BYTES" # OR OIDCGATE_SESSION_ENCRYPTION_KEY +callbackURL: "/oauth2/callback" +logoutURL: "/oauth2/logout" +postLogoutRedirectURI: "/" + +# allowedUserDomains: [company.com] +# excludedURLs: [/health, /metrics] +``` + +- [ ] **Step 3: Add README section** + +In the top-level `README.md`, find the "Install" section (around the top of the file) and append a new section near it: + +```markdown +## Standalone binary (oidcgate) + +If you don't run Traefik, `oidcgate` exposes the same middleware as a +forward-auth daemon for nginx, Caddy, Traefik ForwardAuth, HAProxy, and +Envoy. See [`docs/OIDCGATE.md`](docs/OIDCGATE.md). + +```bash +go build -o oidcgate ./cmd/oidcgate +./oidcgate --config examples/oidcgate.yaml +``` +``` + +Use Edit to insert it after the existing Install section without disturbing surrounding content. + +- [ ] **Step 4: Commit** + +```bash +git add docs/OIDCGATE.md examples/oidcgate.yaml README.md +git commit -m "docs(oidcgate): user-facing setup guide + nginx/Caddy/Traefik wiring examples" +``` + +--- + +## Task 11: Final verification + +- [ ] **Step 1: Run the full test suite** + +```bash +go test ./... +``` + +Expected: PASS for both library and `cmd/oidcgate`. + +- [ ] **Step 2: Build the binary fresh** + +```bash +go build -o /tmp/oidcgate ./cmd/oidcgate +/tmp/oidcgate --help 2>&1 | head -5 +``` + +Expected: usage line showing `-config` flag. + +- [ ] **Step 3: vet + format check** + +```bash +go vet ./... +gofmt -l cmd/oidcgate forwarded_uri.go ready.go +``` + +Expected: no output from either (clean). + +- [ ] **Step 4: Final commit if anything got formatted** + +```bash +git status --short +# If gofmt edited anything: +gofmt -w cmd/oidcgate forwarded_uri.go ready.go +git add -A +git commit -m "chore: gofmt" +``` + +- [ ] **Step 5: Hand-off note** + +Plan complete. Push branch (or merge to main if working directly on main) when verified end-to-end against a real IdP. The README acceptance smoke test (manual Keycloak flow) is the user's call to perform — out of scope for this plan. + +--- + +## Spec Coverage Map + +| Spec section | Implemented in | +|---|---| +| File layout | Tasks 3–9 | +| Endpoint contract — `/oauth2/auth` (silent, 302→401) | Tasks 5, 6 | +| Endpoint contract — `/oauth2/start` | Task 6 | +| Endpoint contract — callback (existing logic) | Task 6 (rewrites path; library logic untouched) | +| Endpoint contract — logout (existing logic) | Task 6 | +| Synthetic `next` mirrors X-* headers | Task 4 | +| 302→401 interceptor | Task 5 | +| Library patch — `TrustForwardedURI` | Task 1 | +| Library patch — `Ready()` accessor | Task 2 | +| YAML+env config loader | Task 3 | +| Lifecycle / graceful shutdown | Tasks 8, 9 | +| Sentinel path `/__oidcgate_protected__` | Task 6 | +| `/healthz`, `/readyz` | Task 7 | +| Tests — `config_test.go`, `endpoints_test.go`, `interceptor_test.go` | Tasks 3, 5, 6 | +| Out-of-scope (TLS, metrics, multi-tenancy, reverse proxy) | Not implemented — per spec | +| Acceptance: `go build ./cmd/oidcgate` | Task 9 step 2 | +| Acceptance: existing test suite still passes | Tasks 1, 2, 9, 11 | +| Acceptance: new endpoint tests pass | Tasks 4–8 | +| Acceptance: binary boots, `/healthz` responds | Task 9 step 3 | +| Acceptance: README documents nginx/Caddy/Traefik wiring | Task 10 |