50 KiB
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 toConfigstruct, around the existing scalar string fields ~settings.go:39-80) -
Modify:
types.go(add field toTraefikOidcstruct alongside existing scalar bools) -
Modify:
main.go:97(NewWithContext— copy field fromConfigonto*TraefikOidc) -
Modify:
auth_flow.go:72,101(use new helper instead ofreq.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
TraefikOidcstruct
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
Configstruct
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 |