10 KiB
oidcgate — Standalone OIDC Forward-Auth Daemon (Tier 1)
Date: 2026-05-19 Status: Design approved, pending implementation plan Scope: Tier 1 only — forward-auth daemon. No reverse-proxy mode, no TLS termination, no metrics, no multi-tenancy.
Goal
Provide a standalone Go binary that exposes the existing traefikoidc OIDC middleware as a forward-auth daemon callable from nginx (auth_request), Caddy (forward_auth), Traefik (ForwardAuth), HAProxy, and Envoy (ext_authz_http). The library's public surface is additive only: no existing exported function signature changes; one optional Config field (TrustForwardedURI) and one new read-only accessor on *TraefikOidc are added. Existing Traefik plugin users see no behavior change unless they opt in to the new field.
Non-Goals
- Reverse-proxy mode (Tier 2 — separate effort if requested).
- TLS termination inside the daemon.
- Built-in Prometheus metrics.
- Multi-tenant routing (one binary serves one OIDC client config).
- oauth2-proxy CLI/flag compatibility.
- Docker image or goreleaser publishing as part of Tier 1.
Architecture
File layout
github.com/lukaszraczylo/traefikoidc (library, unchanged)
├── main.go (existing New/NewWithContext)
├── middleware.go (existing ServeHTTP + ~10 LoC patch)
└── cmd/
└── oidcgate/
├── main.go (entrypoint, flags, signal handling)
├── config.go (YAML loader + env-var override walker)
├── server.go (http.ServeMux wiring, listen loop)
├── endpoints.go (auth/start/callback/logout handlers)
├── success.go (synthetic success handler used as `next`)
├── interceptor.go (302→401 response-writer for /oauth2/auth)
├── health.go (/healthz, /readyz)
├── config_test.go
├── endpoints_test.go
└── interceptor_test.go
Process model
Single binary, single *TraefikOidc instance built at boot from YAML config, served on one listener port. Health endpoints on the same listener. Graceful shutdown on SIGINT/SIGTERM via srv.Shutdown(ctx) with a 15s deadline, after which context cancellation propagates into the existing goroutine manager.
Endpoint Contract
All four endpoints share the listener. Paths are configurable; defaults shown.
| Endpoint | Default path | Method | Contract |
|---|---|---|---|
| Auth probe | /oauth2/auth |
GET | Silent. 200 OK + injected headers on success. 401 on failure (never 302). Consumed by nginx auth_request, Traefik ForwardAuth, Caddy forward_auth, Envoy ext_authz_http. |
| Sign-in | /oauth2/start |
GET | Always 302 to IdP authorize URL with state+nonce+PKCE. Reads target URL from ?rd= query or X-Forwarded-Uri header. Hit by the browser after a 401 from /oauth2/auth. |
| Callback | config.callbackURL |
GET | IdP code+state exchange. Existing auth_flow.go logic runs unchanged. On success → 302 to original URL. |
| Logout | config.logoutURL |
GET/POST | Existing logout.go handler. Terminates session. Honors oidcEndSessionURL if configured. |
Wiring (how each endpoint delegates)
All four handlers feed (*TraefikOidc).ServeHTTP after rewriting req.URL.Path:
/oauth2/callback→ rewrite toconfig.callbackURL, delegate. Middleware path-match atmiddleware.gotriggers callback flow./oauth2/logout→ rewrite toconfig.logoutURL, delegate. Middleware logout path-match at the top ofServeHTTPtriggers./oauth2/start→ rewritereq.URL.Pathto the sentinel/__oidcgate_protected__, delegate. Middleware sees an unauthenticated GET on a protected path, emits the302to IdP. The redirect flows through naturally./oauth2/auth→ rewritereq.URL.Pathto/__oidcgate_protected__, wrapResponseWriterwith an interceptor (see below), delegate.
The sentinel path /__oidcgate_protected__ is chosen because it cannot collide with callbackURL / logoutURL / /health* path matches inside ServeHTTP and is not a likely user-configured excludedURLs entry. It is internal-only: clients never see it.
Synthetic next handler
The middleware calls t.next.ServeHTTP(rw, req) at four sites (middleware.go:174,185,187,592) when the request is authenticated and should be forwarded. The daemon supplies a next that:
- Writes
200 OK. - Mirrors any
X-Forwarded-*and templated headers that the middleware set onreq.Header(e.g.X-Forwarded-Useratmiddleware.go:101,512) onto the response headers, so proxies can capture them viaauth_request_set/authResponseHeaders. - Writes empty body.
302 → 401 interceptor (/oauth2/auth only)
nginx auth_request cannot follow 302s. For the silent endpoint, the daemon wraps the ResponseWriter such that:
- If the middleware writes status
302(the IdP-redirect branch), the interceptor rewrites it to401. Locationheader from the swallowed302is preserved asX-Auth-Redirecton the401response (advisory; some proxies may surface it).Set-Cookieheaders (state, PKCE, nonce) are preserved verbatim so the browser carries them into the subsequent/oauth2/startrequest.- For any non-
302status, the interceptor is a passthrough.
/oauth2/start does not wrap; the middleware's natural 302 flows through to the browser.
Configuration
Source
- File:
--config /etc/oidcgate/config.yaml(path overridable via flag). - Format: YAML, unmarshalled into the existing
traefikoidc.Configstruct (settings.go:39) viayaml.v3(already a dependency ingo.mod). - Migration from Traefik: copy the
plugin.traefikoidc:subtree out of.traefik.ymland add the daemon-specific top-level keys below. - Env-var overrides (secrets in particular): after YAML unmarshal, walk the config struct. Any scalar string/int/bool field with a non-empty
OIDCGATE_<UPPER_SNAKE_CASE_FIELD>env-var replaces the YAML value. Nested structs (Redis,SecurityHeaders,DynamicClientRegistration) stay YAML-only.
Top-level oidcgate-specific keys
listen: ":8080" # required, listener address
authPath: "/oauth2/auth" # optional, default shown
startPath: "/oauth2/start" # optional, default shown
# all other keys = existing traefikoidc.Config fields
Validation
The existing validation inside traefikoidc.NewWithContext (main.go:97) runs unchanged. On any returned error, the daemon logs the error and exits non-zero.
Library-Side Patches
Two additive changes, both default-off / read-only:
-
settings.go+middleware.go(~10 LoC): addConfig.TrustForwardedURI bool(defaultfalse). Whentrue, the post-login-redirect target captured during the "unauthenticated GET → 302 to IdP" branch is sourced fromreq.Header.Get("X-Forwarded-Uri")if non-empty, instead of fromreq.URL. The daemon setsTrustForwardedURI = trueat config build time. Default-off preserves current Traefik plugin behavior exactly. -
main.go(~5 LoC): addfunc (t *TraefikOidc) Ready() boolreturningtrueonce at least one successful OIDC metadata discovery fetch has populated the metadata cache. Read-only; no behavior change for existing consumers.
Lifecycle
parse flags
→ load YAML
→ apply env-var overrides
→ build synthetic success handler
→ call traefikoidc.New(ctx, success, cfg, "oidcgate") (validation happens here)
→ build mux (auth, start, callback, logout, healthz, readyz)
→ http.Server.ListenAndServe on cfg.listen
→ wait for SIGINT/SIGTERM
→ srv.Shutdown(15s ctx)
→ ctx cancel propagates to goroutine manager
→ exit 0
/readyz returns 200 only once traefikoidc.New has returned without error and the first OIDC metadata discovery fetch has succeeded. Implementation: add a read-only accessor func (t *TraefikOidc) Ready() bool that returns true once the metadata cache has at least one successful discovery fetch. The daemon's /readyz handler calls this and returns 200 / 503 accordingly.
/healthz returns 200 as long as the process is alive.
Testing
config_test.go— YAML round-trip; env-var override precedence; validation pass-through.endpoints_test.go—httptest.NewServer-based scenarios:/oauth2/authwith no session →401./oauth2/authwith valid session →200+X-Forwarded-Usermirrored on response./oauth2/start→302with valid IdPauthorizeURL incl.stateand PKCE./oauth2/callback→ completes exchange, sets session, redirects to original URL./oauth2/logout→ clears session cookie.
interceptor_test.go— middleware-emitted302becomes401;Location→X-Auth-Redirect;Set-Cookiepreserved.- Reuse existing mock IdP from
enhanced_mocks_test.go. No new mock infra.
Risks & Mitigations
| Risk | Mitigation |
|---|---|
Interceptor swallows a legitimate 302 from the middleware that isn't the IdP redirect. |
Inspect: only intercept when Location matches t.providerURL's authorize endpoint or when it points off-host. Test coverage in interceptor_test.go. |
| Library-side patch breaks current Traefik users. | New TrustForwardedURI defaults to false; existing path untouched when unset. |
| Env-var walker overreaches into nested structs. | Restrict to top-level scalar fields; document explicitly; nested structs stay YAML-only. |
| Path-rewrite trick hits a middleware path-comparison we didn't anticipate. | All four t.next.ServeHTTP sites verified at middleware.go:174,185,187,592. Endpoint tests exercise each path. |
Out of Scope (Tier 2 candidates)
- Reverse-proxy mode (
httputil.ReverseProxyas the configurednext). - TLS termination (
tls.Config, ACME). - Prometheus metrics endpoint.
- Multi-tenant routing.
- oauth2-proxy flag/env compatibility.
- Goreleaser binaries, Docker image, systemd unit.
Acceptance Criteria
go build ./cmd/oidcgateproduces a working binary.- Existing test suite (
go test ./...) still passes — zero regressions in the library. - New endpoint tests pass for all four endpoints against the mock IdP.
oidcgate --config example.yamlboots, serves/healthz, performs end-to-end OIDC flow against a real IdP (manual smoke test against e.g. a local Keycloak).- README section documents nginx, Caddy, and Traefik wiring examples.