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