Files
traefikoidc/docs/superpowers/plans/2026-05-19-oidcgate.md
T

1824 lines
50 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`, `<callbackURL>`, `<logoutURL>`) 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.<scalar> = config.<Scalar>` 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_<UPPER_SNAKE_CASE> 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_<UPPER_SNAKE_CASE> 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", "[email protected]")
req.Header.Set("X-Forwarded-Email", "[email protected]")
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 != "[email protected]" {
t.Errorf("X-Forwarded-User: want mirrored, got %q", got)
}
if got := rec.Header().Get("X-Forwarded-Email"); got != "[email protected]" {
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 |