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

50 KiB
Raw Blame History

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:

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
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):

	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:

	// 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:

	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:

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
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):

// 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
go test ./...

Expected: PASS. If any test fails, fix before commit.

  • Step 10: Commit
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:

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
go test -run TestReady ./...

Expected: FAIL — Ready undefined.

  • Step 3: Write the implementation

Create ready.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
go test -run TestReady ./...

Expected: PASS (2 tests).

  • Step 5: Run full test suite
go test ./...

Expected: PASS.

  • Step 6: Commit
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:

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
go test ./cmd/oidcgate/...

Expected: FAIL — Load, Config, etc. undefined; package may not build.

  • Step 3: Write the implementation

Create cmd/oidcgate/config.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:

package main

func main() {}

This stub is replaced in Task 9.

  • Step 5: Run config tests
go test ./cmd/oidcgate/...

Expected: PASS (4 config tests).

  • Step 6: Commit
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:

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
go test ./cmd/oidcgate/... -run TestSuccessHandler

Expected: FAIL — newSuccessHandler undefined.

  • Step 3: Write the implementation

Create cmd/oidcgate/success.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
go test ./cmd/oidcgate/... -run TestSuccessHandler

Expected: PASS (3 tests).

  • Step 5: Commit
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:

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
go test ./cmd/oidcgate/... -run TestInterceptor

Expected: FAIL — newAuthInterceptor undefined.

  • Step 3: Write the implementation

Create cmd/oidcgate/interceptor.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
go test ./cmd/oidcgate/... -run TestInterceptor

Expected: PASS (3 tests).

  • Step 5: Commit
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:

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

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
go test ./cmd/oidcgate/... -run 'TestAuth_|TestStart_|TestCallback_|TestLogout_'

Expected: PASS (6 tests).

  • Step 5: Commit
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:

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
go test ./cmd/oidcgate/... -run 'TestHealthz|TestReadyz'

Expected: FAIL — newHealthzHandler, newReadyzHandler undefined.

  • Step 3: Write the implementation

Create cmd/oidcgate/health.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
go test ./cmd/oidcgate/... -run 'TestHealthz|TestReadyz'

Expected: PASS (3 tests).

  • Step 5: Commit
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:

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:

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
go test ./cmd/oidcgate/... -run TestMux

Expected: FAIL — buildMux undefined.

  • Step 3: Write the implementation

Create cmd/oidcgate/server.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
go test ./cmd/oidcgate/...

Expected: PASS (all package tests).

  • Step 5: Commit
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:

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

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
go test ./...

Expected: PASS.

  • Step 5: Commit
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:

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

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

## 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
go test ./...

Expected: PASS for both library and cmd/oidcgate.

  • Step 2: Build the binary fresh
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
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
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