# 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 |