mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-11-04 07:53:59 +00:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fa579ccf4 | ||
|
|
2af05701dc | ||
|
|
03a755cb53 | ||
|
|
dc0e7e0238 | ||
|
|
b2e79d8798 | ||
|
|
52ef32ece7 | ||
|
|
3bf7c60ef4 | ||
|
|
775ca7afc3 | ||
|
|
a1273e6883 | ||
|
|
0bc0079a58 | ||
|
|
20294f1339 | ||
|
|
43938ed8a8 | ||
|
|
46679c82eb | ||
|
|
a46be72be5 | ||
|
|
91966c1bec | ||
|
|
c465fc888b | ||
|
|
047fea3c75 | ||
|
|
0c092a5a22 | ||
|
|
8f458b4f6e | ||
|
|
17c28fd574 | ||
|
|
21cc2ed747 | ||
|
|
ded90e5dc1 | ||
|
|
46777d0510 | ||
|
|
f990365cb8 | ||
|
|
85eb9ecd16 | ||
|
|
3495e70cbb | ||
|
|
fcb21a36e6 | ||
|
|
a6c38c0747 |
+130
-5
@@ -1,13 +1,41 @@
|
|||||||
version: 2
|
version: 2
|
||||||
|
|
||||||
# Traefik plugins are source-only - no binary builds
|
# Two release artefacts:
|
||||||
# Traefik loads plugins via Yaegi interpreter at runtime
|
#
|
||||||
|
# 1. The Traefik plugin: source-only — Traefik loads it via the Yaegi
|
||||||
|
# interpreter from the source tarball published on GitHub releases.
|
||||||
|
# 2. oidcgate: a standalone forward-auth daemon built from cmd/oidcgate.
|
||||||
|
# Shipped as both per-OS/arch binary archives AND a multi-arch Docker
|
||||||
|
# image at ghcr.io/lukaszraczylo/oidcgate, tagged to match the release.
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- skip: true
|
- id: oidcgate
|
||||||
|
main: ./cmd/oidcgate
|
||||||
|
binary: oidcgate
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
|
goos:
|
||||||
|
- linux
|
||||||
|
- darwin
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
flags:
|
||||||
|
- -trimpath
|
||||||
|
- -buildvcs=false
|
||||||
|
ldflags:
|
||||||
|
- -s -w
|
||||||
|
- -X main.version={{.Version}}
|
||||||
|
- -X main.commit={{.ShortCommit}}
|
||||||
|
- -X main.date={{.Date}}
|
||||||
|
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||||
|
|
||||||
# Create source archive for GitHub releases
|
|
||||||
archives:
|
archives:
|
||||||
- formats: [tar.gz]
|
# Source archive for the Traefik plugin path. meta:true → no binary
|
||||||
|
# builds attached; everything comes from `files:` below.
|
||||||
|
- id: source-plugin
|
||||||
|
meta: true
|
||||||
|
formats: [tar.gz]
|
||||||
name_template: "{{ .ProjectName }}_v{{ .Version }}_source"
|
name_template: "{{ .ProjectName }}_v{{ .Version }}_source"
|
||||||
files:
|
files:
|
||||||
- "*.go"
|
- "*.go"
|
||||||
@@ -25,6 +53,93 @@ archives:
|
|||||||
- "!regression/**"
|
- "!regression/**"
|
||||||
- "!examples/**"
|
- "!examples/**"
|
||||||
- "!docs/**"
|
- "!docs/**"
|
||||||
|
- "!cmd/**"
|
||||||
|
|
||||||
|
# Per-OS/arch binary archives for the oidcgate daemon.
|
||||||
|
- id: oidcgate
|
||||||
|
ids: [oidcgate]
|
||||||
|
formats: [tar.gz]
|
||||||
|
name_template: "oidcgate_v{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||||
|
files:
|
||||||
|
- LICENSE*
|
||||||
|
- README*
|
||||||
|
- src: docs/OIDCGATE.md
|
||||||
|
dst: docs/
|
||||||
|
- src: examples/oidcgate.yaml
|
||||||
|
dst: examples/
|
||||||
|
|
||||||
|
# Build a Docker image per (linux, arch) combo. Tag suffixes are
|
||||||
|
# combined into a single multi-arch manifest list below via
|
||||||
|
# docker_manifests, so end users pull a single tag.
|
||||||
|
dockers:
|
||||||
|
- id: oidcgate-amd64
|
||||||
|
ids: [oidcgate]
|
||||||
|
goos: linux
|
||||||
|
goarch: amd64
|
||||||
|
image_templates:
|
||||||
|
- "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}-amd64"
|
||||||
|
use: buildx
|
||||||
|
dockerfile: cmd/oidcgate/Dockerfile
|
||||||
|
build_flag_templates:
|
||||||
|
- "--pull"
|
||||||
|
- "--platform=linux/amd64"
|
||||||
|
- "--label=org.opencontainers.image.title=oidcgate"
|
||||||
|
- "--label=org.opencontainers.image.description=Standalone OIDC forward-auth daemon for nginx/Caddy/Traefik/HAProxy/Envoy"
|
||||||
|
- "--label=org.opencontainers.image.version={{ .Version }}"
|
||||||
|
- "--label=org.opencontainers.image.revision={{ .FullCommit }}"
|
||||||
|
- "--label=org.opencontainers.image.created={{ .Date }}"
|
||||||
|
- "--label=org.opencontainers.image.source=https://github.com/lukaszraczylo/traefikoidc"
|
||||||
|
- "--label=org.opencontainers.image.url=https://github.com/lukaszraczylo/traefikoidc"
|
||||||
|
- "--label=org.opencontainers.image.documentation=https://github.com/lukaszraczylo/traefikoidc/blob/main/docs/OIDCGATE.md"
|
||||||
|
- "--label=org.opencontainers.image.licenses=MIT"
|
||||||
|
|
||||||
|
- id: oidcgate-arm64
|
||||||
|
ids: [oidcgate]
|
||||||
|
goos: linux
|
||||||
|
goarch: arm64
|
||||||
|
image_templates:
|
||||||
|
- "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}-arm64"
|
||||||
|
use: buildx
|
||||||
|
dockerfile: cmd/oidcgate/Dockerfile
|
||||||
|
build_flag_templates:
|
||||||
|
- "--pull"
|
||||||
|
- "--platform=linux/arm64"
|
||||||
|
- "--label=org.opencontainers.image.title=oidcgate"
|
||||||
|
- "--label=org.opencontainers.image.description=Standalone OIDC forward-auth daemon for nginx/Caddy/Traefik/HAProxy/Envoy"
|
||||||
|
- "--label=org.opencontainers.image.version={{ .Version }}"
|
||||||
|
- "--label=org.opencontainers.image.revision={{ .FullCommit }}"
|
||||||
|
- "--label=org.opencontainers.image.created={{ .Date }}"
|
||||||
|
- "--label=org.opencontainers.image.source=https://github.com/lukaszraczylo/traefikoidc"
|
||||||
|
- "--label=org.opencontainers.image.url=https://github.com/lukaszraczylo/traefikoidc"
|
||||||
|
- "--label=org.opencontainers.image.documentation=https://github.com/lukaszraczylo/traefikoidc/blob/main/docs/OIDCGATE.md"
|
||||||
|
- "--label=org.opencontainers.image.licenses=MIT"
|
||||||
|
|
||||||
|
# Multi-arch manifests — these are what users actually pull.
|
||||||
|
# Tags match the release tag (vX.Y.Z) exactly, plus a few convenience tags.
|
||||||
|
docker_manifests:
|
||||||
|
- name_template: "ghcr.io/lukaszraczylo/oidcgate:v{{ .Version }}"
|
||||||
|
image_templates:
|
||||||
|
- "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}-amd64"
|
||||||
|
- "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}-arm64"
|
||||||
|
- name_template: "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}"
|
||||||
|
image_templates:
|
||||||
|
- "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}-amd64"
|
||||||
|
- "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}-arm64"
|
||||||
|
- name_template: "ghcr.io/lukaszraczylo/oidcgate:v{{ .Major }}.{{ .Minor }}"
|
||||||
|
image_templates:
|
||||||
|
- "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}-amd64"
|
||||||
|
- "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}-arm64"
|
||||||
|
skip_push: auto
|
||||||
|
- name_template: "ghcr.io/lukaszraczylo/oidcgate:v{{ .Major }}"
|
||||||
|
image_templates:
|
||||||
|
- "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}-amd64"
|
||||||
|
- "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}-arm64"
|
||||||
|
skip_push: auto
|
||||||
|
- name_template: "ghcr.io/lukaszraczylo/oidcgate:latest"
|
||||||
|
image_templates:
|
||||||
|
- "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}-amd64"
|
||||||
|
- "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}-arm64"
|
||||||
|
skip_push: auto
|
||||||
|
|
||||||
checksum:
|
checksum:
|
||||||
name_template: "{{ .ProjectName }}_v{{ .Version }}_checksums.txt"
|
name_template: "{{ .ProjectName }}_v{{ .Version }}_checksums.txt"
|
||||||
@@ -58,3 +173,13 @@ signs:
|
|||||||
- "--yes"
|
- "--yes"
|
||||||
artifacts: checksum
|
artifacts: checksum
|
||||||
output: true
|
output: true
|
||||||
|
|
||||||
|
# Sign the Docker images and manifests with cosign keyless.
|
||||||
|
docker_signs:
|
||||||
|
- cmd: cosign
|
||||||
|
artifacts: all
|
||||||
|
args:
|
||||||
|
- sign
|
||||||
|
- "${artifact}@${digest}"
|
||||||
|
- "--yes"
|
||||||
|
output: true
|
||||||
|
|||||||
@@ -64,6 +64,28 @@ cosign verify-blob \
|
|||||||
traefikoidc_v<version>_checksums.txt
|
traefikoidc_v<version>_checksums.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 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
|
||||||
|
# From source
|
||||||
|
go build -o oidcgate ./cmd/oidcgate
|
||||||
|
./oidcgate --config examples/oidcgate.yaml
|
||||||
|
|
||||||
|
# Or pull the released image (multi-arch: linux/amd64, linux/arm64)
|
||||||
|
docker run --rm \
|
||||||
|
-v /path/to/config.yaml:/etc/oidcgate/config.yaml:ro \
|
||||||
|
-p 8080:8080 \
|
||||||
|
ghcr.io/lukaszraczylo/oidcgate:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Each tagged release publishes a Docker image at
|
||||||
|
`ghcr.io/lukaszraczylo/oidcgate:vX.Y.Z` (matching the release tag), plus
|
||||||
|
floating `:vX.Y`, `:vX`, and `:latest` aliases.
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -411,19 +433,6 @@ namespaced claims, Cognito regions, GitLab self-hosted) live in
|
|||||||
|
|
||||||
Set `logLevel: debug` to surface detail.
|
Set `logLevel: debug` to surface detail.
|
||||||
|
|
||||||
## Telemetry
|
|
||||||
|
|
||||||
On first plugin instantiation this middleware sends a single anonymous
|
|
||||||
adoption ping — project name, version, timestamp; no identifiers, no
|
|
||||||
request data, no token contents. Fire-and-forget with a 2-second timeout;
|
|
||||||
cannot block plugin load or panic.
|
|
||||||
|
|
||||||
Local source: [`telemetry.go`](./telemetry.go). Disclosure mirrors
|
|
||||||
**[oss-telemetry — Disabling telemetry](https://github.com/lukaszraczylo/oss-telemetry#disabling-telemetry)**.
|
|
||||||
|
|
||||||
Quick opt-out: set any of `DO_NOT_TRACK=1`, `OSS_TELEMETRY_DISABLED=1`,
|
|
||||||
or `TRAEFIKOIDC_DISABLE_TELEMETRY=1`.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
See [LICENSE](LICENSE).
|
See [LICENSE](LICENSE).
|
||||||
|
|||||||
+2
-2
@@ -69,7 +69,7 @@ func (t *TraefikOidc) prepareSessionForAuthentication(session *SessionData, csrf
|
|||||||
// - session: The session data to prepare for authentication.
|
// - session: The session data to prepare for authentication.
|
||||||
// - redirectURL: The pre-calculated callback URL (redirect_uri) for this middleware instance.
|
// - redirectURL: The pre-calculated callback URL (redirect_uri) for this middleware instance.
|
||||||
func (t *TraefikOidc) defaultInitiateAuthentication(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) {
|
func (t *TraefikOidc) defaultInitiateAuthentication(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) {
|
||||||
t.logger.Debugf("Initiating new OIDC authentication flow for request: %s", req.URL.RequestURI())
|
t.logger.Debugf("Initiating new OIDC authentication flow for request: %s", t.originalRequestURI(req))
|
||||||
|
|
||||||
// Check and handle redirect limits
|
// Check and handle redirect limits
|
||||||
if err := t.validateRedirectCount(session, rw, req); err != nil {
|
if err := t.validateRedirectCount(session, rw, req); err != nil {
|
||||||
@@ -98,7 +98,7 @@ func (t *TraefikOidc) defaultInitiateAuthentication(rw http.ResponseWriter, req
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clear existing session data and set new authentication state
|
// Clear existing session data and set new authentication state
|
||||||
t.prepareSessionForAuthentication(session, csrfToken, nonce, codeVerifier, req.URL.RequestURI())
|
t.prepareSessionForAuthentication(session, csrfToken, nonce, codeVerifier, t.originalRequestURI(req))
|
||||||
|
|
||||||
session.MarkDirty()
|
session.MarkDirty()
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -71,8 +71,8 @@ func makeBearerOIDC(t *testing.T, next http.Handler) *TraefikOidc {
|
|||||||
logger: NewLogger("error"),
|
logger: NewLogger("error"),
|
||||||
initComplete: make(chan struct{}),
|
initComplete: make(chan struct{}),
|
||||||
sessionManager: sm,
|
sessionManager: sm,
|
||||||
firstRequestStarted: 1,
|
firstRequestReceived: true,
|
||||||
metadataRefreshStartedAtomic: 1,
|
metadataRefreshStarted: true,
|
||||||
issuerURL: "https://issuer.example.com",
|
issuerURL: "https://issuer.example.com",
|
||||||
audience: "https://api.example.com",
|
audience: "https://api.example.com",
|
||||||
clientID: "https://api.example.com",
|
clientID: "https://api.example.com",
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
#
|
||||||
|
# This Dockerfile is consumed by GoReleaser. The binary is built outside
|
||||||
|
# the Docker context (by goreleaser's Go cross-compile) and placed in the
|
||||||
|
# build context as ./oidcgate before `docker buildx build` runs.
|
||||||
|
#
|
||||||
|
# To build locally without goreleaser:
|
||||||
|
# go build -o oidcgate ./cmd/oidcgate
|
||||||
|
# docker build -f cmd/oidcgate/Dockerfile -t oidcgate:dev .
|
||||||
|
FROM gcr.io/distroless/static-debian12:nonroot
|
||||||
|
|
||||||
|
ARG TARGETOS
|
||||||
|
ARG TARGETARCH
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.title="oidcgate"
|
||||||
|
LABEL org.opencontainers.image.description="Standalone OIDC forward-auth daemon for nginx/Caddy/Traefik/HAProxy/Envoy"
|
||||||
|
LABEL org.opencontainers.image.source="https://github.com/lukaszraczylo/traefikoidc"
|
||||||
|
LABEL org.opencontainers.image.documentation="https://github.com/lukaszraczylo/traefikoidc/blob/main/docs/OIDCGATE.md"
|
||||||
|
LABEL org.opencontainers.image.licenses="MIT"
|
||||||
|
|
||||||
|
COPY oidcgate /usr/local/bin/oidcgate
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
USER nonroot:nonroot
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/oidcgate"]
|
||||||
|
CMD ["--config", "/etc/oidcgate/config.yaml"]
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"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:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// envScalarFields 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) {
|
||||||
|
// Clean the operator-supplied path to satisfy gosec G304 (file inclusion
|
||||||
|
// via variable). filepath.Clean strips traversal sequences and normalises
|
||||||
|
// the path; this is canonical mitigation for config files supplied via a
|
||||||
|
// CLI flag — the operator runs the daemon, so the input is trusted, but
|
||||||
|
// gosec's static analysis still flags variable paths without the cleanup.
|
||||||
|
clean := filepath.Clean(path)
|
||||||
|
data, err := os.ReadFile(clean) // #nosec G304 -- operator-supplied config path, cleaned above
|
||||||
|
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)
|
||||||
|
|
||||||
|
if cfg.Listen == "" {
|
||||||
|
return nil, fmt.Errorf("config: missing required 'listen' (or OIDCGATE_LISTEN env var)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(cfg.OIDC.CallbackURL, "/") {
|
||||||
|
return nil, fmt.Errorf("config: callbackURL must be a path starting with '/', got %q", cfg.OIDC.CallbackURL)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(cfg.OIDC.LogoutURL, "/") {
|
||||||
|
return nil, fmt.Errorf("config: logoutURL must be a path starting with '/', got %q", cfg.OIDC.LogoutURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
reserved := []string{
|
||||||
|
sentinelPath,
|
||||||
|
cfg.AuthPath,
|
||||||
|
cfg.StartPath,
|
||||||
|
cfg.OIDC.CallbackURL,
|
||||||
|
cfg.OIDC.LogoutURL,
|
||||||
|
}
|
||||||
|
for _, ex := range cfg.OIDC.ExcludedURLs {
|
||||||
|
for _, r := range reserved {
|
||||||
|
if r != "" && strings.HasPrefix(r, ex) {
|
||||||
|
return nil, fmt.Errorf("config: excludedURL %q would bypass reserved oidcgate path %q", ex, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
runes := []rune(s)
|
||||||
|
var b strings.Builder
|
||||||
|
for i, r := range runes {
|
||||||
|
if i > 0 && isUpper(r) {
|
||||||
|
prev := runes[i-1]
|
||||||
|
next := rune(0)
|
||||||
|
if i+1 < len(runes) {
|
||||||
|
next = runes[i+1]
|
||||||
|
}
|
||||||
|
if !isUpper(prev) || (next != 0 && !isUpper(next)) {
|
||||||
|
b.WriteByte('_')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteRune(unicode.ToUpper(r))
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func isUpper(r rune) bool { return r >= 'A' && r <= 'Z' }
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// minimalYAML is a base config accepted by Load with no surprises.
|
||||||
|
const minimalYAML = `
|
||||||
|
listen: ":8080"
|
||||||
|
providerURL: "https://idp.example"
|
||||||
|
clientID: "abc"
|
||||||
|
clientSecret: "secret"
|
||||||
|
sessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
callbackURL: "/oauth2/callback"
|
||||||
|
logoutURL: "/oauth2/logout"
|
||||||
|
`
|
||||||
|
|
||||||
|
func writeConfig(t *testing.T, content string) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_NestedStructRoundTrip(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"
|
||||||
|
redis:
|
||||||
|
address: "redis:6379"
|
||||||
|
password: "redispw"
|
||||||
|
`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.OIDC.Redis == nil {
|
||||||
|
t.Fatal("redis block should populate cfg.OIDC.Redis")
|
||||||
|
}
|
||||||
|
if cfg.OIDC.Redis.Address != "redis:6379" {
|
||||||
|
t.Errorf("redis address: want redis:6379, got %q", cfg.OIDC.Redis.Address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix 5: callbackURL / logoutURL must start with "/"
|
||||||
|
func TestLoad_RejectsAbsoluteCallbackURL(t *testing.T) {
|
||||||
|
path := writeConfig(t, `
|
||||||
|
listen: ":8080"
|
||||||
|
providerURL: "https://idp.example"
|
||||||
|
clientID: "abc"
|
||||||
|
clientSecret: "secret"
|
||||||
|
sessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
callbackURL: "https://app.example.com/oauth2/callback"
|
||||||
|
logoutURL: "/oauth2/logout"
|
||||||
|
`)
|
||||||
|
if _, err := Load(path); err == nil {
|
||||||
|
t.Fatal("callbackURL with absolute URL must be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_RejectsAbsoluteLogoutURL(t *testing.T) {
|
||||||
|
path := writeConfig(t, `
|
||||||
|
listen: ":8080"
|
||||||
|
providerURL: "https://idp.example"
|
||||||
|
clientID: "abc"
|
||||||
|
clientSecret: "secret"
|
||||||
|
sessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
callbackURL: "/oauth2/callback"
|
||||||
|
logoutURL: "https://app.example.com/oauth2/logout"
|
||||||
|
`)
|
||||||
|
if _, err := Load(path); err == nil {
|
||||||
|
t.Fatal("logoutURL with absolute URL must be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix 2: excludedURLs must not prefix reserved paths
|
||||||
|
func TestLoad_RejectsExcludedURLPrefixingReservedPath(t *testing.T) {
|
||||||
|
path := writeConfig(t, `
|
||||||
|
listen: ":8080"
|
||||||
|
providerURL: "https://idp.example"
|
||||||
|
clientID: "abc"
|
||||||
|
clientSecret: "secret"
|
||||||
|
sessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
callbackURL: "/oauth2/callback"
|
||||||
|
logoutURL: "/oauth2/logout"
|
||||||
|
excludedURLs: ["/"]
|
||||||
|
`)
|
||||||
|
if _, err := Load(path); err == nil {
|
||||||
|
t.Fatal("excludedURLs: ['/'] must be rejected (bypasses all reserved paths)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_AllowsNonOverlappingExcludedURL(t *testing.T) {
|
||||||
|
path := writeConfig(t, minimalYAML+`excludedURLs: ["/public"]
|
||||||
|
`)
|
||||||
|
if _, err := Load(path); err != nil {
|
||||||
|
t.Fatalf("non-overlapping excludedURL must be accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix 3: env override coverage — every envScalarFields entry must have a
|
||||||
|
// matching case in setScalarField. isAllZeroForField detects drift.
|
||||||
|
func TestEnvOverrideCoverage(t *testing.T) {
|
||||||
|
for _, field := range envScalarFields {
|
||||||
|
field := field
|
||||||
|
t.Run(field, func(t *testing.T) {
|
||||||
|
probe := "/safe/probe-" + field
|
||||||
|
if field == "SessionEncryptionKey" {
|
||||||
|
probe = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
|
||||||
|
}
|
||||||
|
if field == "LogLevel" {
|
||||||
|
probe = "debug"
|
||||||
|
}
|
||||||
|
if field == "ClientAuthMethod" {
|
||||||
|
probe = "client_secret_post"
|
||||||
|
}
|
||||||
|
if field == "ClientAssertionAlg" {
|
||||||
|
probe = "RS256"
|
||||||
|
}
|
||||||
|
|
||||||
|
var fresh Config
|
||||||
|
setScalarField(&fresh, field, probe)
|
||||||
|
if isAllZeroForField(&fresh, field, probe) {
|
||||||
|
t.Fatalf("envScalarFields includes %q but setScalarField has no matching case (drift)", field)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAllZeroForField returns true when setScalarField did NOT set the expected
|
||||||
|
// field — i.e., the switch is missing a case for `field`.
|
||||||
|
func isAllZeroForField(cfg *Config, field, probe string) bool {
|
||||||
|
switch field {
|
||||||
|
case "Listen":
|
||||||
|
return cfg.Listen != probe
|
||||||
|
case "AuthPath":
|
||||||
|
return cfg.AuthPath != probe
|
||||||
|
case "StartPath":
|
||||||
|
return cfg.StartPath != probe
|
||||||
|
case "ProviderURL":
|
||||||
|
return cfg.OIDC.ProviderURL != probe
|
||||||
|
case "ClientID":
|
||||||
|
return cfg.OIDC.ClientID != probe
|
||||||
|
case "ClientSecret":
|
||||||
|
return cfg.OIDC.ClientSecret != probe
|
||||||
|
case "Audience":
|
||||||
|
return cfg.OIDC.Audience != probe
|
||||||
|
case "CallbackURL":
|
||||||
|
return cfg.OIDC.CallbackURL != probe
|
||||||
|
case "LogoutURL":
|
||||||
|
return cfg.OIDC.LogoutURL != probe
|
||||||
|
case "PostLogoutRedirectURI":
|
||||||
|
return cfg.OIDC.PostLogoutRedirectURI != probe
|
||||||
|
case "SessionEncryptionKey":
|
||||||
|
return cfg.OIDC.SessionEncryptionKey != probe
|
||||||
|
case "CookiePrefix":
|
||||||
|
return cfg.OIDC.CookiePrefix != probe
|
||||||
|
case "CookieDomain":
|
||||||
|
return cfg.OIDC.CookieDomain != probe
|
||||||
|
case "LogLevel":
|
||||||
|
return cfg.OIDC.LogLevel != probe
|
||||||
|
case "RevocationURL":
|
||||||
|
return cfg.OIDC.RevocationURL != probe
|
||||||
|
case "OIDCEndSessionURL":
|
||||||
|
return cfg.OIDC.OIDCEndSessionURL != probe
|
||||||
|
case "UserIdentifierClaim":
|
||||||
|
return cfg.OIDC.UserIdentifierClaim != probe
|
||||||
|
case "GroupClaimName":
|
||||||
|
return cfg.OIDC.GroupClaimName != probe
|
||||||
|
case "RoleClaimName":
|
||||||
|
return cfg.OIDC.RoleClaimName != probe
|
||||||
|
case "ClientAuthMethod":
|
||||||
|
return cfg.OIDC.ClientAuthMethod != probe
|
||||||
|
case "ClientAssertionPrivateKey":
|
||||||
|
return cfg.OIDC.ClientAssertionPrivateKey != probe
|
||||||
|
case "ClientAssertionKeyPath":
|
||||||
|
return cfg.OIDC.ClientAssertionKeyPath != probe
|
||||||
|
case "ClientAssertionKeyID":
|
||||||
|
return cfg.OIDC.ClientAssertionKeyID != probe
|
||||||
|
case "ClientAssertionAlg":
|
||||||
|
return cfg.OIDC.ClientAssertionAlg != probe
|
||||||
|
case "CACertPath":
|
||||||
|
return cfg.OIDC.CACertPath != probe
|
||||||
|
case "CACertPEM":
|
||||||
|
return cfg.OIDC.CACertPEM != probe
|
||||||
|
}
|
||||||
|
return true // unknown field → drift
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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)
|
||||||
|
defer ic.Finalize()
|
||||||
|
r2 := cloneAndRewrite(req, sentinelPath)
|
||||||
|
next.ServeHTTP(ic, r2)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
// Precedence: explicit ?rd= wins over an ambient upstream
|
||||||
|
// X-Forwarded-Uri so /oauth2/start?rd=/dashboard does not get
|
||||||
|
// silently overridden by the proxy's current-URL forwarding.
|
||||||
|
if rd := req.URL.Query().Get("rd"); rd != "" {
|
||||||
|
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 clone of req with URL.Path set to newPath.
|
||||||
|
// req.Clone deep-copies URL via net/http's cloneURL, so mutating
|
||||||
|
// r2.URL.Path does not affect the original req. RawQuery, Host,
|
||||||
|
// Fragment, RawPath are preserved unchanged.
|
||||||
|
func cloneAndRewrite(req *http.Request, newPath string) *http.Request {
|
||||||
|
r2 := req.Clone(req.Context())
|
||||||
|
r2.URL.Path = newPath
|
||||||
|
return r2
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
if got := stub.calls[0].header.Get("X-Forwarded-Uri"); got != "/protected/page" {
|
||||||
|
t.Errorf("X-Forwarded-Uri must pass through to middleware: want /protected/page, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 TestStart_RdQueryWinsOverUpstreamHeader(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=/explicit", nil)
|
||||||
|
req.Header.Set("X-Forwarded-Uri", "/ambient")
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if got := stub.calls[0].header.Get("X-Forwarded-Uri"); got != "/explicit" {
|
||||||
|
t.Fatalf("?rd= must win over upstream X-Forwarded-Uri: want /explicit, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/lukaszraczylo/traefikoidc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeProviderHost is a synthetic hostname used in place of the httptest.Server's
|
||||||
|
// 127.0.0.1 address. traefikoidc's URL validator blocks loopback IPs
|
||||||
|
// unconditionally; a non-loopback hostname passes the check. The custom HTTP
|
||||||
|
// client returned by mockHTTPClient rewires all dials for this host to the
|
||||||
|
// actual test-server port, so the mock IdP still receives every request.
|
||||||
|
const fakeProviderHost = "test-oidc-provider.local"
|
||||||
|
|
||||||
|
// mockHTTPClient returns an *http.Client whose dialer transparently redirects
|
||||||
|
// connections to fakeProviderHost to the real httptest.Server address.
|
||||||
|
func mockHTTPClient(realAddr string) *http.Client {
|
||||||
|
dialer := &net.Dialer{Timeout: 5 * time.Second}
|
||||||
|
transport := &http.Transport{
|
||||||
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
host, _, err := net.SplitHostPort(addr)
|
||||||
|
if err != nil {
|
||||||
|
return dialer.DialContext(ctx, network, addr)
|
||||||
|
}
|
||||||
|
if host == fakeProviderHost {
|
||||||
|
addr = realAddr
|
||||||
|
}
|
||||||
|
return dialer.DialContext(ctx, network, addr)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return &http.Client{Transport: transport}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newMockIdP returns an httptest.Server that serves the minimal OIDC
|
||||||
|
// discovery surface required by traefikoidc.NewWithContext to bootstrap
|
||||||
|
// — discovery doc + an empty JWKS. All URLs in the discovery doc use
|
||||||
|
// fakeProviderHost so they pass the middleware's URL security validator.
|
||||||
|
func newMockIdP(t *testing.T) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
fakeBase := "http://" + fakeProviderHost
|
||||||
|
mux.HandleFunc("/.well-known/openid-configuration", func(rw http.ResponseWriter, _ *http.Request) {
|
||||||
|
discovery := map[string]any{
|
||||||
|
"issuer": fakeBase,
|
||||||
|
"authorization_endpoint": fakeBase + "/authorize",
|
||||||
|
"token_endpoint": fakeBase + "/token",
|
||||||
|
"jwks_uri": fakeBase + "/jwks",
|
||||||
|
"response_types_supported": []string{"code"},
|
||||||
|
"subject_types_supported": []string{"public"},
|
||||||
|
"id_token_signing_alg_values_supported": []string{"RS256"},
|
||||||
|
}
|
||||||
|
rw.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(rw).Encode(discovery)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/jwks", func(rw http.ResponseWriter, _ *http.Request) {
|
||||||
|
rw.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = rw.Write([]byte(`{"keys":[]}`))
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTestConfig produces a Config that points at the fake provider hostname
|
||||||
|
// (which the custom HTTP client redirects to the real mock server) and uses
|
||||||
|
// a known-good SessionEncryptionKey + safe path defaults.
|
||||||
|
func buildTestConfig(srv *httptest.Server) *Config {
|
||||||
|
// realAddr is HOST:PORT of the httptest server (e.g. "127.0.0.1:56789").
|
||||||
|
realAddr := srv.Listener.Addr().String()
|
||||||
|
cfg := &Config{
|
||||||
|
Listen: "127.0.0.1:0", // unused — we drive the mux directly via httptest
|
||||||
|
AuthPath: "/oauth2/auth",
|
||||||
|
StartPath: "/oauth2/start",
|
||||||
|
OIDC: traefikoidc.Config{
|
||||||
|
ProviderURL: "http://" + fakeProviderHost,
|
||||||
|
ClientID: "test-client",
|
||||||
|
ClientSecret: "test-secret",
|
||||||
|
SessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||||
|
CallbackURL: "/oauth2/callback",
|
||||||
|
LogoutURL: "/oauth2/logout",
|
||||||
|
TrustForwardedURI: true,
|
||||||
|
EnablePKCE: true,
|
||||||
|
HTTPClient: mockHTTPClient(realAddr),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildIntegrationStack builds the same wiring main.go builds: real
|
||||||
|
// middleware constructed against the mock IdP, success handler as next,
|
||||||
|
// mux on top.
|
||||||
|
func buildIntegrationStack(t *testing.T, idp *httptest.Server) (http.Handler, *traefikoidc.TraefikOidc) {
|
||||||
|
t.Helper()
|
||||||
|
cfg := buildTestConfig(idp)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
mw, err := traefikoidc.NewWithContext(ctx, &cfg.OIDC, newSuccessHandler(), "oidcgate-test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewWithContext: %v", err)
|
||||||
|
}
|
||||||
|
mux := buildMux(cfg, mw, mw)
|
||||||
|
return mux, mw
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegration_UnauthenticatedAuthReturns401WithRedirect(t *testing.T) {
|
||||||
|
idp := newMockIdP(t)
|
||||||
|
mux, _ := buildIntegrationStack(t, idp)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/oauth2/auth", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("status: want 401, got %d (body=%q)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
loc := rec.Header().Get("X-Auth-Redirect")
|
||||||
|
if loc == "" {
|
||||||
|
t.Fatal("X-Auth-Redirect should carry the IdP authorize URL")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(loc, "http://"+fakeProviderHost+"/authorize") {
|
||||||
|
t.Errorf("X-Auth-Redirect should point at the mock IdP authorize endpoint, got %q", loc)
|
||||||
|
}
|
||||||
|
if cookies := rec.Header().Values("Set-Cookie"); len(cookies) == 0 {
|
||||||
|
t.Error("expected at least one Set-Cookie (state/PKCE/nonce) on 401")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegration_StartRedirectsToIdPWithStateAndPKCE(t *testing.T) {
|
||||||
|
idp := newMockIdP(t)
|
||||||
|
mux, _ := buildIntegrationStack(t, idp)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/oauth2/start?rd=/dashboard", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("status: want 302, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
loc := rec.Header().Get("Location")
|
||||||
|
if !strings.HasPrefix(loc, "http://"+fakeProviderHost+"/authorize") {
|
||||||
|
t.Fatalf("Location: want prefix http://%s/authorize, got %q", fakeProviderHost, loc)
|
||||||
|
}
|
||||||
|
if !strings.Contains(loc, "state=") {
|
||||||
|
t.Errorf("Location should include state= param, got %q", loc)
|
||||||
|
}
|
||||||
|
if !strings.Contains(loc, "code_challenge=") {
|
||||||
|
t.Errorf("Location should include code_challenge= param (PKCE), got %q", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegration_HealthzAlways200(t *testing.T) {
|
||||||
|
idp := newMockIdP(t)
|
||||||
|
mux, _ := buildIntegrationStack(t, idp)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("healthz: want 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegration_ReadyzBecomes200AfterDiscovery(t *testing.T) {
|
||||||
|
idp := newMockIdP(t)
|
||||||
|
mux, mw := buildIntegrationStack(t, idp)
|
||||||
|
|
||||||
|
// Hit /oauth2/auth once to trigger metadata discovery (the middleware
|
||||||
|
// performs discovery lazily on first request).
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/oauth2/auth", nil))
|
||||||
|
|
||||||
|
// Poll Ready() until true or timeout.
|
||||||
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if mw.Ready() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if !mw.Ready() {
|
||||||
|
t.Fatal("middleware should be Ready() within 3s after first request triggered discovery")
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("readyz post-discovery: want 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
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 a redirect class (302, 303, 307, 308), 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
|
||||||
|
finalized 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) { //nolint:unparam // signature mandated by http.ResponseWriter
|
||||||
|
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() {
|
||||||
|
if w.finalized {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.finalized = true
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInterceptor_307TemporaryRedirectIntercepted(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
w := newAuthInterceptor(rec)
|
||||||
|
w.Header().Set("Location", "/elsewhere")
|
||||||
|
w.WriteHeader(http.StatusTemporaryRedirect)
|
||||||
|
w.Finalize()
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("307 should be intercepted to 401, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInterceptor_308PermanentRedirectIntercepted(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
w := newAuthInterceptor(rec)
|
||||||
|
w.Header().Set("Location", "/elsewhere")
|
||||||
|
w.WriteHeader(http.StatusPermanentRedirect)
|
||||||
|
w.Finalize()
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("308 should be intercepted to 401, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInterceptor_DoubleFinalizeIsNoop(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
w := newAuthInterceptor(rec)
|
||||||
|
w.Header().Set("X-Forwarded-User", "alice")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Finalize()
|
||||||
|
// Second call must not panic, must not change anything observable.
|
||||||
|
w.Finalize()
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("double Finalize must not change status, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("X-Forwarded-User"); got != "alice" {
|
||||||
|
t.Errorf("double Finalize must not duplicate headers, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
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 {
|
||||||
|
cancel()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Printf("oidcgate: listening on %s", cfg.Listen)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
cancel()
|
||||||
|
log.Fatalf("oidcgate: serve: %v", err)
|
||||||
|
}
|
||||||
|
log.Println("oidcgate: stopped")
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
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 { //nolint:unused // consumed by main.go in Task 9
|
||||||
|
return &http.Server{
|
||||||
|
Addr: cfg.Listen,
|
||||||
|
Handler: mux,
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
WriteTimeout: 30 * time.Second,
|
||||||
|
IdleTimeout: 120 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// shutdown gracefully stops the server with a 15s deadline.
|
||||||
|
func shutdown(srv *http.Server) error { //nolint:unused // consumed by main.go in Task 9
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return srv.Shutdown(ctx)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
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-") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
canonical := http.CanonicalHeaderKey(name)
|
||||||
|
_, ok := mirrorAllowedHeaders[canonical]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSuccessHandler_MultiValueHeader(t *testing.T) {
|
||||||
|
h := newSuccessHandler()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||||
|
req.Header.Add("X-Role", "admin")
|
||||||
|
req.Header.Add("X-Role", "editor")
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
got := rec.Header()["X-Role"]
|
||||||
|
if len(got) != 2 || got[0] != "admin" || got[1] != "editor" {
|
||||||
|
t.Errorf("X-Role multi-value: want [admin editor], got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ Complete reference for all Traefik OIDC middleware configuration options.
|
|||||||
- [Security Headers](#security-headers)
|
- [Security Headers](#security-headers)
|
||||||
- [Scope Configuration](#scope-configuration)
|
- [Scope Configuration](#scope-configuration)
|
||||||
- [Advanced Options](#advanced-options)
|
- [Advanced Options](#advanced-options)
|
||||||
|
- [Standalone binary (oidcgate)](#standalone-binary-oidcgate)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -664,3 +665,19 @@ sessionEncryptionKey: ${OIDC_SECRET_API}
|
|||||||
# Good
|
# Good
|
||||||
sessionEncryptionKey: ${OIDC_SECRET_SVC}
|
sessionEncryptionKey: ${OIDC_SECRET_SVC}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Standalone binary (oidcgate)
|
||||||
|
|
||||||
|
If you don't run Traefik, the same configuration shape documented above
|
||||||
|
works for the [`oidcgate`](OIDCGATE.md) standalone forward-auth daemon
|
||||||
|
under `cmd/oidcgate`. Three extra top-level keys (`listen`, `authPath`,
|
||||||
|
`startPath`) configure the daemon itself; everything else maps 1:1 onto
|
||||||
|
the `traefikoidc.Config` fields documented in this reference.
|
||||||
|
|
||||||
|
See [`docs/OIDCGATE.md`](OIDCGATE.md) for the full daemon guide including
|
||||||
|
nginx, Caddy, Traefik ForwardAuth, HAProxy and Envoy wiring snippets,
|
||||||
|
the `OIDCGATE_*` environment-variable inventory, the security posture
|
||||||
|
(X-Forwarded-Uri sanitisation, excludedURLs guardrail), and how to layer
|
||||||
|
M2M [bearer-token auth](BEARER_AUTH.md) on the same daemon.
|
||||||
|
|||||||
@@ -0,0 +1,362 @@
|
|||||||
|
# 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`.
|
||||||
|
|
||||||
|
## Table of contents
|
||||||
|
|
||||||
|
- [Build](#build)
|
||||||
|
- [Run](#run)
|
||||||
|
- [Configuration](#configuration)
|
||||||
|
- [YAML file](#yaml-file)
|
||||||
|
- [Environment-variable overrides](#environment-variable-overrides)
|
||||||
|
- [Endpoints](#endpoints)
|
||||||
|
- [Reverse-proxy snippets](#reverse-proxy-snippets)
|
||||||
|
- [nginx (`auth_request`)](#nginx-auth_request)
|
||||||
|
- [Caddy (`forward_auth`)](#caddy-forward_auth)
|
||||||
|
- [Traefik (`ForwardAuth`)](#traefik-forwardauth)
|
||||||
|
- [HAProxy](#haproxy)
|
||||||
|
- [Envoy (`ext_authz_http`)](#envoy-ext_authz_http)
|
||||||
|
- [Security posture](#security-posture)
|
||||||
|
- [Bearer-token (M2M) auth on the same daemon](#bearer-token-m2m-auth-on-the-same-daemon)
|
||||||
|
- [Operational guidance](#operational-guidance)
|
||||||
|
- [Debugging](#debugging)
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o oidcgate ./cmd/oidcgate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./oidcgate --config /etc/oidcgate/config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
The daemon parses `--config`, loads YAML, applies any `OIDCGATE_*` env-var
|
||||||
|
overrides, validates the result, and binds to `listen`. On SIGINT/SIGTERM it
|
||||||
|
calls `http.Server.Shutdown` with a 15s deadline, draining in-flight requests.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### YAML file
|
||||||
|
|
||||||
|
The OIDC subtree of the config maps 1:1 onto the [`traefikoidc.Config`](CONFIGURATION.md)
|
||||||
|
struct — every field documented under "Configuration Reference" works here
|
||||||
|
verbatim. Three extra top-level keys configure the daemon itself:
|
||||||
|
|
||||||
|
| Key | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `listen` | _required_ | TCP address (e.g. `:8080`, `127.0.0.1:8080`). |
|
||||||
|
| `authPath` | `/oauth2/auth` | Silent-probe endpoint (used by nginx `auth_request`). |
|
||||||
|
| `startPath` | `/oauth2/start` | Visible sign-in endpoint. |
|
||||||
|
|
||||||
|
Minimal example (see [`examples/oidcgate.yaml`](../examples/oidcgate.yaml)):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
listen: ":8080"
|
||||||
|
providerURL: "https://accounts.google.com"
|
||||||
|
clientID: "your-client-id"
|
||||||
|
clientSecret: "your-client-secret"
|
||||||
|
sessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
callbackURL: "/oauth2/callback"
|
||||||
|
logoutURL: "/oauth2/logout"
|
||||||
|
```
|
||||||
|
|
||||||
|
Nested structs (`redis:`, `securityHeaders:`, `dynamicClientRegistration:`)
|
||||||
|
round-trip cleanly through YAML — same shape as in `.traefik.yml`.
|
||||||
|
|
||||||
|
### Environment-variable overrides
|
||||||
|
|
||||||
|
Any of the following scalar fields can be overridden at runtime by an
|
||||||
|
`OIDCGATE_<UPPER_SNAKE_CASE>` environment variable. The env var wins over
|
||||||
|
the YAML value when set and non-empty. Intended for secret injection
|
||||||
|
(K8s `valueFrom.secretKeyRef`, systemd `EnvironmentFile=`, etc.).
|
||||||
|
|
||||||
|
| YAML key | Env var |
|
||||||
|
|---|---|
|
||||||
|
| `listen` | `OIDCGATE_LISTEN` |
|
||||||
|
| `authPath` | `OIDCGATE_AUTH_PATH` |
|
||||||
|
| `startPath` | `OIDCGATE_START_PATH` |
|
||||||
|
| `providerURL` | `OIDCGATE_PROVIDER_URL` |
|
||||||
|
| `clientID` | `OIDCGATE_CLIENT_ID` |
|
||||||
|
| `clientSecret` | `OIDCGATE_CLIENT_SECRET` |
|
||||||
|
| `audience` | `OIDCGATE_AUDIENCE` |
|
||||||
|
| `callbackURL` | `OIDCGATE_CALLBACK_URL` |
|
||||||
|
| `logoutURL` | `OIDCGATE_LOGOUT_URL` |
|
||||||
|
| `postLogoutRedirectURI` | `OIDCGATE_POST_LOGOUT_REDIRECT_URI` |
|
||||||
|
| `sessionEncryptionKey` | `OIDCGATE_SESSION_ENCRYPTION_KEY` |
|
||||||
|
| `cookiePrefix` | `OIDCGATE_COOKIE_PREFIX` |
|
||||||
|
| `cookieDomain` | `OIDCGATE_COOKIE_DOMAIN` |
|
||||||
|
| `logLevel` | `OIDCGATE_LOG_LEVEL` |
|
||||||
|
| `revocationURL` | `OIDCGATE_REVOCATION_URL` |
|
||||||
|
| `oidcEndSessionURL` | `OIDCGATE_OIDC_END_SESSION_URL` |
|
||||||
|
| `userIdentifierClaim` | `OIDCGATE_USER_IDENTIFIER_CLAIM` |
|
||||||
|
| `groupClaimName` | `OIDCGATE_GROUP_CLAIM_NAME` |
|
||||||
|
| `roleClaimName` | `OIDCGATE_ROLE_CLAIM_NAME` |
|
||||||
|
| `clientAuthMethod` | `OIDCGATE_CLIENT_AUTH_METHOD` |
|
||||||
|
| `clientAssertionPrivateKey` | `OIDCGATE_CLIENT_ASSERTION_PRIVATE_KEY` |
|
||||||
|
| `clientAssertionKeyPath` | `OIDCGATE_CLIENT_ASSERTION_KEY_PATH` |
|
||||||
|
| `clientAssertionKeyID` | `OIDCGATE_CLIENT_ASSERTION_KEY_ID` |
|
||||||
|
| `clientAssertionAlg` | `OIDCGATE_CLIENT_ASSERTION_ALG` |
|
||||||
|
| `caCertPath` | `OIDCGATE_CA_CERT_PATH` |
|
||||||
|
| `caCertPEM` | `OIDCGATE_CA_CERT_PEM` |
|
||||||
|
|
||||||
|
Nested-struct fields (Redis, security headers, DCR) are YAML-only — set
|
||||||
|
them in the config file, not via env.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
| Path | Method | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `/oauth2/auth` | GET | Silent probe — `200` if authenticated, `401` if not. Never returns `302`; the middleware's redirect-to-IdP is rewritten in-flight to `401` with the original `Location` carried as `X-Auth-Redirect`. |
|
||||||
|
| `/oauth2/start` | GET | Visible sign-in — `302` to the IdP authorize URL. Accepts `?rd=<safe-path>` (or honours `X-Forwarded-Uri`) for the post-login redirect target. |
|
||||||
|
| `/oauth2/callback` | GET | IdP `code`+`state` exchange. Path is configurable via `callbackURL`. |
|
||||||
|
| `/oauth2/logout` | GET/POST | Terminates the session. Path is configurable via `logoutURL`. Honours `oidcEndSessionURL` for RP-initiated logout. |
|
||||||
|
| `/healthz` | GET | Liveness — `200` while the process is alive. |
|
||||||
|
| `/readyz` | GET | Readiness — `200` once the OIDC discovery document has been fetched, otherwise `503`. |
|
||||||
|
|
||||||
|
## Reverse-proxy snippets
|
||||||
|
|
||||||
|
### nginx (`auth_request`)
|
||||||
|
|
||||||
|
```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 (`forward_auth`)
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
example.com {
|
||||||
|
forward_auth oidcgate:8080 {
|
||||||
|
uri /oauth2/auth
|
||||||
|
copy_headers X-Forwarded-User X-Forwarded-Email
|
||||||
|
@denied status 401
|
||||||
|
handle_response @denied {
|
||||||
|
redir /oauth2/start?rd={http.request.uri} 302
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
Traefik can follow the `X-Auth-Redirect` value via a chained `redirectScheme`
|
||||||
|
middleware, or you can configure the upstream router to redirect `401` →
|
||||||
|
`/oauth2/start` directly.
|
||||||
|
|
||||||
|
### HAProxy
|
||||||
|
|
||||||
|
```haproxy
|
||||||
|
frontend fe_https
|
||||||
|
bind *:443 ssl crt /etc/haproxy/certs/site.pem
|
||||||
|
http-request set-var(req.orig_uri) path
|
||||||
|
http-request send-spoe-group oidc auth-check # or use lua/SPOE; simplest is the lua snippet below
|
||||||
|
|
||||||
|
# The simpler pattern: dispatch /oauth2/* to oidcgate, everything else
|
||||||
|
# goes through a Lua filter that issues a sub-request to /oauth2/auth.
|
||||||
|
acl is_oidc_endpoint path_beg /oauth2/
|
||||||
|
use_backend be_oidcgate if is_oidc_endpoint
|
||||||
|
default_backend be_app
|
||||||
|
|
||||||
|
backend be_oidcgate
|
||||||
|
server oidcgate1 oidcgate:8080
|
||||||
|
|
||||||
|
backend be_app
|
||||||
|
server app1 backend:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
HAProxy does not have a first-class `auth_request` equivalent in pure
|
||||||
|
config — the canonical patterns are SPOE (Stream Processing Offload Engine),
|
||||||
|
a Lua filter that issues `/oauth2/auth` and reads the response, or a
|
||||||
|
sidecar that does the dance. Reach for SPOE for high-throughput
|
||||||
|
production; Lua is simpler for low-volume.
|
||||||
|
|
||||||
|
### Envoy (`ext_authz_http`)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
http_filters:
|
||||||
|
- name: envoy.filters.http.ext_authz
|
||||||
|
typed_config:
|
||||||
|
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz
|
||||||
|
transport_api_version: V3
|
||||||
|
http_service:
|
||||||
|
server_uri:
|
||||||
|
uri: http://oidcgate:8080
|
||||||
|
cluster: oidcgate
|
||||||
|
timeout: 2s
|
||||||
|
path_prefix: /oauth2/auth
|
||||||
|
authorization_request:
|
||||||
|
allowed_headers:
|
||||||
|
patterns:
|
||||||
|
- exact: cookie
|
||||||
|
- exact: authorization
|
||||||
|
- prefix: x-forwarded-
|
||||||
|
authorization_response:
|
||||||
|
allowed_upstream_headers:
|
||||||
|
patterns:
|
||||||
|
- exact: x-forwarded-user
|
||||||
|
- exact: x-forwarded-email
|
||||||
|
allowed_client_headers:
|
||||||
|
patterns:
|
||||||
|
- exact: x-auth-redirect
|
||||||
|
- exact: set-cookie
|
||||||
|
```
|
||||||
|
|
||||||
|
On `401`, the `X-Auth-Redirect` header is surfaced to the downstream client
|
||||||
|
via `allowed_client_headers`. A small Envoy `router` filter or
|
||||||
|
`local_reply_config` rule can convert that into a browser-facing `302`
|
||||||
|
redirect to `/oauth2/start`.
|
||||||
|
|
||||||
|
## Security posture
|
||||||
|
|
||||||
|
- **`X-Forwarded-Uri` is sanitised.** The daemon forces
|
||||||
|
`TrustForwardedURI=true` so the middleware honours `X-Forwarded-Uri` for
|
||||||
|
the post-login redirect target. To prevent open redirects (CWE-601),
|
||||||
|
the value is rejected unless it is a safe same-origin path: must start
|
||||||
|
with `/`, must NOT start with `//` (protocol-relative), and must have
|
||||||
|
no scheme or host after parsing. Absolute URLs or anything that could
|
||||||
|
redirect off-origin falls through to `req.URL.RequestURI()`.
|
||||||
|
|
||||||
|
- **`excludedURLs` cannot bypass the daemon's own paths.** At config
|
||||||
|
load, the loader rejects any `excludedURLs` entry that is a prefix of
|
||||||
|
`authPath`, `startPath`, `callbackURL`, `logoutURL`, or the internal
|
||||||
|
sentinel path. A misconfiguration like `excludedURLs: ["/"]` (common
|
||||||
|
"allow all then add auth selectively" mistake) is rejected at startup
|
||||||
|
with a descriptive error.
|
||||||
|
|
||||||
|
- **`callbackURL` and `logoutURL` must be paths.** Absolute URLs are
|
||||||
|
rejected at config load — both because `http.ServeMux.Handle` panics
|
||||||
|
on non-`/` patterns and because the middleware's path-match would
|
||||||
|
silently fail.
|
||||||
|
|
||||||
|
- **`listen` is required.** Empty or missing `listen` is rejected at
|
||||||
|
startup rather than failing later at `net.Listen`.
|
||||||
|
|
||||||
|
- **Secrets via env vars.** `clientSecret` and `sessionEncryptionKey`
|
||||||
|
can be supplied via env vars instead of YAML so they don't end up on
|
||||||
|
disk if you use a secret manager.
|
||||||
|
|
||||||
|
## Bearer-token (M2M) auth on the same daemon
|
||||||
|
|
||||||
|
oidcgate uses the full `traefikoidc.Config` shape, so the bearer-token
|
||||||
|
M2M auth path documented in [`BEARER_AUTH.md`](BEARER_AUTH.md) works
|
||||||
|
out of the box. Add to your YAML:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
enableBearerAuth: true
|
||||||
|
audience: "https://api.example.com"
|
||||||
|
bearerIdentifierClaim: "sub"
|
||||||
|
# stripAuthorizationHeader: true # default
|
||||||
|
# bearerOverridesCookie: false # default — cookie wins on collision
|
||||||
|
```
|
||||||
|
|
||||||
|
With this set, the daemon accepts both:
|
||||||
|
- Browser users hitting `/oauth2/auth` → cookie session flow.
|
||||||
|
- API clients calling the protected backend with `Authorization: Bearer <jwt>`
|
||||||
|
→ bearer validation, principal headers, no session.
|
||||||
|
|
||||||
|
The bearer path doesn't go through `/oauth2/auth` separately — it's
|
||||||
|
applied by the middleware on every request the daemon sees, before the
|
||||||
|
cookie session check. See [BEARER_AUTH.md](BEARER_AUTH.md) for the full
|
||||||
|
threat model, identifier sanitisation rules, and failure-response
|
||||||
|
matrix.
|
||||||
|
|
||||||
|
## Operational guidance
|
||||||
|
|
||||||
|
- **Run behind a fronting proxy on a private network.** The daemon does
|
||||||
|
not terminate TLS. Put it on a localhost socket or a private subnet
|
||||||
|
reachable only from your nginx/Caddy/Traefik/HAProxy/Envoy.
|
||||||
|
- **`/healthz` and `/readyz` are unauthenticated** — correct for
|
||||||
|
Kubernetes liveness/readiness probes, but **do not expose them past a
|
||||||
|
load balancer**. Restrict via an ACL: nginx `allow 10.0.0.0/8; deny
|
||||||
|
all;`, Caddy `@health remote_ip 10.0.0.0/8`, k8s NetworkPolicy, or
|
||||||
|
your CNI of choice.
|
||||||
|
- **Multi-replica deployments** need a shared session store. Enable the
|
||||||
|
`redis:` block in the config (see [`docs/REDIS.md`](REDIS.md)) so
|
||||||
|
sessions survive a hop between replicas.
|
||||||
|
- **No built-in Prometheus metrics yet.** If you need request-level
|
||||||
|
visibility, take it from your fronting proxy's access logs — both
|
||||||
|
nginx and Envoy can tag `auth_request` / `ext_authz` outcomes.
|
||||||
|
- **Logs are minimal by default.** Set `logLevel: debug` while
|
||||||
|
bringing up a new deployment; raise to `info` (default) or higher
|
||||||
|
once stable. Debug logs include path-match decisions and metadata
|
||||||
|
refresh outcomes.
|
||||||
|
- **Graceful shutdown is 15s.** SIGINT or SIGTERM triggers
|
||||||
|
`http.Server.Shutdown(ctx)` with a 15-second deadline; in-flight
|
||||||
|
requests are allowed to complete. If your orchestrator's grace
|
||||||
|
period is shorter, requests can be cut mid-flight.
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
- **Requests appear as `/__oidcgate_protected__` in middleware debug
|
||||||
|
logs.** This is the internal sentinel path used when `/oauth2/auth`
|
||||||
|
and `/oauth2/start` delegate into the traefikoidc middleware. The
|
||||||
|
upstream client never sees it; it only shows up in the middleware's
|
||||||
|
own `Debugf` output when `logLevel: debug` is set.
|
||||||
|
|
||||||
|
- **`/oauth2/auth` returns `401` with `X-Auth-Redirect` header on
|
||||||
|
unauthenticated requests.** This is the deliberate translation of the
|
||||||
|
middleware's `302` to make nginx `auth_request` work. The browser is
|
||||||
|
redirected via the fronting proxy's `error_page 401 = @oidc_signin;`
|
||||||
|
pattern, not by following the daemon's response directly.
|
||||||
|
|
||||||
|
- **`/readyz` stays `503` after startup.** The middleware fetches the
|
||||||
|
OIDC discovery document lazily on first request, so `/readyz` returns
|
||||||
|
`503` until at least one request has triggered metadata discovery.
|
||||||
|
Hit `/oauth2/auth` once after startup to warm it up — many K8s
|
||||||
|
setups achieve the same effect because the liveness probe already
|
||||||
|
goes through the proxy chain.
|
||||||
|
|
||||||
|
- **Cookie/session diagnostics.** With `logLevel: debug` the middleware
|
||||||
|
logs which session manager was selected (in-memory vs Redis), whether
|
||||||
|
cookies decrypted successfully, and the JWT validation outcome.
|
||||||
|
|
||||||
|
- **Open-redirect rejections are silent.** When the daemon ignores an
|
||||||
|
unsafe `X-Forwarded-Uri` value, it falls back to `req.URL.RequestURI()`
|
||||||
|
without logging. This is intentional (no recon signal) — if a user
|
||||||
|
reports "I keep landing on the wrong page after login", inspect
|
||||||
|
whether the upstream proxy is forwarding a non-canonical
|
||||||
|
`X-Forwarded-Uri` value.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,173 @@
|
|||||||
|
# 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 to `config.callbackURL`, delegate. Middleware path-match at `middleware.go` triggers callback flow.
|
||||||
|
- `/oauth2/logout` → rewrite to `config.logoutURL`, delegate. Middleware logout path-match at the top of `ServeHTTP` triggers.
|
||||||
|
- `/oauth2/start` → rewrite `req.URL.Path` to the sentinel `/__oidcgate_protected__`, delegate. Middleware sees an unauthenticated GET on a protected path, emits the `302` to IdP. The redirect flows through naturally.
|
||||||
|
- `/oauth2/auth` → rewrite `req.URL.Path` to `/__oidcgate_protected__`, wrap `ResponseWriter` with 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:
|
||||||
|
|
||||||
|
1. Writes `200 OK`.
|
||||||
|
2. Mirrors any `X-Forwarded-*` and templated headers that the middleware set on `req.Header` (e.g. `X-Forwarded-User` at `middleware.go:101,512`) onto the **response** headers, so proxies can capture them via `auth_request_set` / `authResponseHeaders`.
|
||||||
|
3. Writes empty body.
|
||||||
|
|
||||||
|
### 302 → 401 interceptor (`/oauth2/auth` only)
|
||||||
|
|
||||||
|
nginx `auth_request` cannot follow `302`s. 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 to `401`.
|
||||||
|
- `Location` header from the swallowed `302` is preserved as `X-Auth-Redirect` on the `401` response (advisory; some proxies may surface it).
|
||||||
|
- `Set-Cookie` headers (state, PKCE, nonce) are preserved verbatim so the browser carries them into the subsequent `/oauth2/start` request.
|
||||||
|
- For any non-`302` status, 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.Config` struct (`settings.go:39`) via `yaml.v3` (already a dependency in `go.mod`).
|
||||||
|
- **Migration from Traefik:** copy the `plugin.traefikoidc:` subtree out of `.traefik.yml` and 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
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
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:
|
||||||
|
|
||||||
|
1. **`settings.go` + `middleware.go` (~10 LoC):** add `Config.TrustForwardedURI bool` (default `false`). When `true`, the post-login-redirect target captured during the "unauthenticated GET → 302 to IdP" branch is sourced from `req.Header.Get("X-Forwarded-Uri")` if non-empty, instead of from `req.URL`. The daemon sets `TrustForwardedURI = true` at config build time. Default-off preserves current Traefik plugin behavior exactly.
|
||||||
|
|
||||||
|
2. **`main.go` (~5 LoC):** add `func (t *TraefikOidc) Ready() bool` returning `true` once 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/auth` with no session → `401`.
|
||||||
|
- `/oauth2/auth` with valid session → `200` + `X-Forwarded-User` mirrored on response.
|
||||||
|
- `/oauth2/start` → `302` with valid IdP `authorize` URL incl. `state` and PKCE.
|
||||||
|
- `/oauth2/callback` → completes exchange, sets session, redirects to original URL.
|
||||||
|
- `/oauth2/logout` → clears session cookie.
|
||||||
|
- `interceptor_test.go` — middleware-emitted `302` becomes `401`; `Location` → `X-Auth-Redirect`; `Set-Cookie` preserved.
|
||||||
|
- 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.ReverseProxy` as the configured `next`).
|
||||||
|
- TLS termination (`tls.Config`, ACME).
|
||||||
|
- Prometheus metrics endpoint.
|
||||||
|
- Multi-tenant routing.
|
||||||
|
- oauth2-proxy flag/env compatibility.
|
||||||
|
- Goreleaser binaries, Docker image, systemd unit.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
1. `go build ./cmd/oidcgate` produces a working binary.
|
||||||
|
2. Existing test suite (`go test ./...`) still passes — zero regressions in the library.
|
||||||
|
3. New endpoint tests pass for all four endpoints against the mock IdP.
|
||||||
|
4. `oidcgate --config example.yaml` boots, serves `/healthz`, performs end-to-end OIDC flow against a real IdP (manual smoke test against e.g. a local Keycloak).
|
||||||
|
5. README section documents nginx, Caddy, and Traefik wiring examples.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# 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]
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package traefikoidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 carries a safe same-origin path, that header
|
||||||
|
// wins. Otherwise (or if the header is missing/unsafe), falls back to
|
||||||
|
// req.URL.RequestURI() — the path the request reached the proxy with.
|
||||||
|
//
|
||||||
|
// "Safe" means: starts with "/", does NOT start with "//" (protocol-relative
|
||||||
|
// URLs can change host), and has no scheme or host after parsing. This
|
||||||
|
// prevents an attacker-controllable header from triggering an open redirect
|
||||||
|
// via http.Redirect later in the auth flow.
|
||||||
|
func (t *TraefikOidc) originalRequestURI(req *http.Request) string {
|
||||||
|
if t.trustForwardedURI {
|
||||||
|
if v := req.Header.Get("X-Forwarded-Uri"); v != "" && isSafeRedirectTarget(v) {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return req.URL.RequestURI()
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSafeRedirectTarget(v string) bool {
|
||||||
|
if !strings.HasPrefix(v, "/") || strings.HasPrefix(v, "//") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
u, err := url.Parse(v)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return u.Host == "" && u.Scheme == ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestOriginalRequestURI_RejectsAbsoluteURL(t *testing.T) {
|
||||||
|
tr := &TraefikOidc{trustForwardedURI: true}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||||
|
req.Header.Set("X-Forwarded-Uri", "https://evil.example/phish")
|
||||||
|
if got := tr.originalRequestURI(req); got != "/protected" {
|
||||||
|
t.Fatalf("absolute URL must be rejected, want /protected fallback, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOriginalRequestURI_RejectsProtocolRelative(t *testing.T) {
|
||||||
|
tr := &TraefikOidc{trustForwardedURI: true}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||||
|
req.Header.Set("X-Forwarded-Uri", "//evil.example/phish")
|
||||||
|
if got := tr.originalRequestURI(req); got != "/protected" {
|
||||||
|
t.Fatalf("protocol-relative URL must be rejected, want /protected fallback, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOriginalRequestURI_AcceptsSafePathWithQuery(t *testing.T) {
|
||||||
|
tr := &TraefikOidc{trustForwardedURI: true}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||||
|
req.Header.Set("X-Forwarded-Uri", "/safe?x=1&y=2")
|
||||||
|
if got := tr.originalRequestURI(req); got != "/safe?x=1&y=2" {
|
||||||
|
t.Fatalf("safe path with query must be accepted, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOriginalRequestURI_RejectsBareHostnameNoSlash(t *testing.T) {
|
||||||
|
tr := &TraefikOidc{trustForwardedURI: true}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||||
|
req.Header.Set("X-Forwarded-Uri", "evil.example/phish")
|
||||||
|
if got := tr.originalRequestURI(req); got != "/protected" {
|
||||||
|
t.Fatalf("non-/ prefix must be rejected, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -478,10 +478,11 @@ func TestRefreshCoordinatorIntegration(t *testing.T) {
|
|||||||
|
|
||||||
// Test 3: Rate limiting
|
// Test 3: Rate limiting
|
||||||
t.Run("RateLimiting", func(t *testing.T) {
|
t.Run("RateLimiting", func(t *testing.T) {
|
||||||
// Reset circuit breaker to closed state for this test. All fields are
|
// Reset circuit breaker to closed state for this test
|
||||||
// atomic so we don't need any mutex.
|
coordinator.circuitBreaker.mutex.Lock()
|
||||||
atomic.StoreInt32(&coordinator.circuitBreaker.state, 0) // closed
|
atomic.StoreInt32(&coordinator.circuitBreaker.state, 0) // closed
|
||||||
atomic.StoreInt32(&coordinator.circuitBreaker.failures, 0)
|
atomic.StoreInt32(&coordinator.circuitBreaker.failures, 0)
|
||||||
|
coordinator.circuitBreaker.mutex.Unlock()
|
||||||
|
|
||||||
// Temporarily increase circuit breaker threshold to not interfere
|
// Temporarily increase circuit breaker threshold to not interfere
|
||||||
oldMaxFailures := coordinator.circuitBreaker.config.MaxFailures
|
oldMaxFailures := coordinator.circuitBreaker.config.MaxFailures
|
||||||
@@ -524,11 +525,9 @@ func TestRefreshCoordinatorIntegration(t *testing.T) {
|
|||||||
time.Sleep(config.CleanupInterval * 3)
|
time.Sleep(config.CleanupInterval * 3)
|
||||||
|
|
||||||
// Old sessions should be cleaned up
|
// Old sessions should be cleaned up
|
||||||
count := 0
|
coordinator.attemptsMutex.RLock()
|
||||||
coordinator.sessionRefreshAttempts.Range(func(_, _ interface{}) bool {
|
count := len(coordinator.sessionRefreshAttempts)
|
||||||
count++
|
coordinator.attemptsMutex.RUnlock()
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
// Should have fewer sessions after cleanup
|
// Should have fewer sessions after cleanup
|
||||||
if count > 10 {
|
if count > 10 {
|
||||||
|
|||||||
@@ -53,26 +53,10 @@ type JWKSet struct {
|
|||||||
Keys []JWK `json:"keys"`
|
Keys []JWK `json:"keys"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// JWKCache provides thread-safe caching of JWKS using UniversalCache.
|
// JWKCache provides thread-safe caching of JWKS using UniversalCache
|
||||||
//
|
|
||||||
// inflightFetches deduplicates concurrent fetches for the same JWKS URL.
|
|
||||||
// It replaces a global sync.RWMutex that was previously held for the entire
|
|
||||||
// HTTP round-trip in GetJWKS: on a cold cache (cold pod, JWK rotation, brief
|
|
||||||
// network blip) every concurrent request piled up on that single Lock(), and
|
|
||||||
// under Yaegi each Lock acquisition costs 10-50ms of interpreter-dispatch
|
|
||||||
// overhead. The singleflight pattern keeps the cold-cache cost O(1) HTTP
|
|
||||||
// fetch regardless of how many requests are waiting.
|
|
||||||
type JWKCache struct {
|
type JWKCache struct {
|
||||||
cache *UniversalCache
|
cache *UniversalCache
|
||||||
inflightFetches sync.Map // map[jwksURL string]*jwksFetch
|
mutex sync.RWMutex
|
||||||
}
|
|
||||||
|
|
||||||
// jwksFetch represents an in-flight JWKS fetch. Done is closed when the fetch
|
|
||||||
// completes; jwks and err carry the result (one of them is set, never both).
|
|
||||||
type jwksFetch struct {
|
|
||||||
done chan struct{}
|
|
||||||
jwks *JWKSet
|
|
||||||
err error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// JWKCacheInterface defines the contract for JWK caching implementations.
|
// JWKCacheInterface defines the contract for JWK caching implementations.
|
||||||
@@ -99,58 +83,36 @@ func NewJWKCache() *JWKCache {
|
|||||||
// request refetches from the upstream. JWK rotation is rare and a per-replica
|
// request refetches from the upstream. JWK rotation is rare and a per-replica
|
||||||
// HTTP fetch on cold cache is cheap, so cross-replica coherence buys nothing.
|
// HTTP fetch on cold cache is cheap, so cross-replica coherence buys nothing.
|
||||||
func (c *JWKCache) GetJWKS(ctx context.Context, jwksURL string, httpClient *http.Client) (*JWKSet, error) {
|
func (c *JWKCache) GetJWKS(ctx context.Context, jwksURL string, httpClient *http.Client) (*JWKSet, error) {
|
||||||
// Fast path: cache hit.
|
// Check cache first
|
||||||
if cachedValue, found := c.cache.GetLocal(jwksURL); found {
|
if cachedValue, found := c.cache.GetLocal(jwksURL); found {
|
||||||
if jwks, ok := cachedValue.(*JWKSet); ok {
|
if jwks, ok := cachedValue.(*JWKSet); ok {
|
||||||
return jwks, nil
|
return jwks, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleflight: dedupe concurrent fetches per URL key. The first arrival
|
c.mutex.Lock()
|
||||||
// performs the HTTP fetch; any later arrival for the same URL waits on
|
defer c.mutex.Unlock()
|
||||||
// its done channel and shares the result. No global lock is held during
|
|
||||||
// the fetch.
|
|
||||||
candidate := &jwksFetch{done: make(chan struct{})}
|
|
||||||
if existing, loaded := c.inflightFetches.LoadOrStore(jwksURL, candidate); loaded {
|
|
||||||
f, _ := existing.(*jwksFetch)
|
|
||||||
select {
|
|
||||||
case <-f.done:
|
|
||||||
return f.jwks, f.err
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil, ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// We're the leader. Make absolutely sure the result fields and the
|
// Double-check after acquiring lock
|
||||||
// in-flight map entry are cleaned up before any waiter unblocks.
|
|
||||||
defer func() {
|
|
||||||
c.inflightFetches.Delete(jwksURL)
|
|
||||||
close(candidate.done)
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Re-check the cache in case a concurrent fetch completed between our
|
|
||||||
// initial miss and our LoadOrStore win.
|
|
||||||
if cachedValue, found := c.cache.GetLocal(jwksURL); found {
|
if cachedValue, found := c.cache.GetLocal(jwksURL); found {
|
||||||
if jwks, ok := cachedValue.(*JWKSet); ok {
|
if jwks, ok := cachedValue.(*JWKSet); ok {
|
||||||
candidate.jwks = jwks
|
|
||||||
return jwks, nil
|
return jwks, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch from URL
|
||||||
jwks, err := fetchJWKS(ctx, jwksURL, httpClient)
|
jwks, err := fetchJWKS(ctx, jwksURL, httpClient)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
candidate.err = err
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(jwks.Keys) == 0 {
|
if len(jwks.Keys) == 0 {
|
||||||
candidate.err = fmt.Errorf("JWKS response contains no keys")
|
return nil, fmt.Errorf("JWKS response contains no keys")
|
||||||
return nil, candidate.err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache for 1 hour.
|
// Cache for 1 hour
|
||||||
_ = c.cache.SetLocal(jwksURL, jwks, 1*time.Hour) // Safe to ignore: cache failures are non-critical
|
_ = c.cache.SetLocal(jwksURL, jwks, 1*time.Hour) // Safe to ignore: cache failures are non-critical
|
||||||
|
|
||||||
candidate.jwks = jwks
|
|
||||||
return jwks, nil
|
return jwks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -415,8 +415,8 @@ func TestMiddlewareBackchannelLogoutRouting(t *testing.T) {
|
|||||||
clientID: "test-client",
|
clientID: "test-client",
|
||||||
issuerURL: "https://provider.example.com",
|
issuerURL: "https://provider.example.com",
|
||||||
initComplete: make(chan struct{}),
|
initComplete: make(chan struct{}),
|
||||||
firstRequestStarted: 1,
|
firstRequestReceived: true,
|
||||||
metadataRefreshStartedAtomic: 1,
|
metadataRefreshStarted: true,
|
||||||
logoutURLPath: "/logout",
|
logoutURLPath: "/logout",
|
||||||
}
|
}
|
||||||
close(oidc.initComplete)
|
close(oidc.initComplete)
|
||||||
@@ -457,8 +457,8 @@ func TestMiddlewareFrontchannelLogoutRouting(t *testing.T) {
|
|||||||
clientID: "test-client",
|
clientID: "test-client",
|
||||||
issuerURL: "https://provider.example.com",
|
issuerURL: "https://provider.example.com",
|
||||||
initComplete: make(chan struct{}),
|
initComplete: make(chan struct{}),
|
||||||
firstRequestStarted: 1,
|
firstRequestReceived: true,
|
||||||
metadataRefreshStartedAtomic: 1,
|
metadataRefreshStarted: true,
|
||||||
logoutURLPath: "/logout",
|
logoutURLPath: "/logout",
|
||||||
}
|
}
|
||||||
close(oidc.initComplete)
|
close(oidc.initComplete)
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ var defaultExcludedURLs = map[string]struct{}{
|
|||||||
// - The configured TraefikOidc handler ready to process requests.
|
// - The configured TraefikOidc handler ready to process requests.
|
||||||
// - An error if essential configuration is missing or invalid (e.g., short encryption key).
|
// - An error if essential configuration is missing or invalid (e.g., short encryption key).
|
||||||
func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error) {
|
func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error) {
|
||||||
sendTelemetry(pluginVersion)
|
|
||||||
return NewWithContext(ctx, config, next, name)
|
return NewWithContext(ctx, config, next, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +201,7 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name
|
|||||||
}(),
|
}(),
|
||||||
forceHTTPS: config.ForceHTTPS,
|
forceHTTPS: config.ForceHTTPS,
|
||||||
enablePKCE: config.EnablePKCE,
|
enablePKCE: config.EnablePKCE,
|
||||||
|
trustForwardedURI: config.TrustForwardedURI,
|
||||||
overrideScopes: config.OverrideScopes,
|
overrideScopes: config.OverrideScopes,
|
||||||
strictAudienceValidation: config.StrictAudienceValidation,
|
strictAudienceValidation: config.StrictAudienceValidation,
|
||||||
allowOpaqueTokens: config.AllowOpaqueTokens,
|
allowOpaqueTokens: config.AllowOpaqueTokens,
|
||||||
@@ -517,19 +517,6 @@ func (t *TraefikOidc) updateMetadataEndpoints(metadata *ProviderMetadata) {
|
|||||||
introspectionURL := t.introspectionURL
|
introspectionURL := t.introspectionURL
|
||||||
registrationURL := t.registrationURL
|
registrationURL := t.registrationURL
|
||||||
|
|
||||||
// Publish the read-mostly URL bundle atomically. Hot-path readers Load
|
|
||||||
// this directly instead of acquiring metadataMu.RLock per request.
|
|
||||||
t.metadataSnapshot.Store(&MetadataSnapshot{
|
|
||||||
IssuerURL: metadata.Issuer,
|
|
||||||
JWKSURL: metadata.JWKSURL,
|
|
||||||
TokenURL: metadata.TokenURL,
|
|
||||||
AuthURL: metadata.AuthURL,
|
|
||||||
RevocationURL: metadata.RevokeURL,
|
|
||||||
EndSessionURL: metadata.EndSessionURL,
|
|
||||||
IntrospectionURL: metadata.IntrospectionURL,
|
|
||||||
RegistrationURL: metadata.RegistrationURL,
|
|
||||||
})
|
|
||||||
|
|
||||||
t.metadataMu.Unlock()
|
t.metadataMu.Unlock()
|
||||||
|
|
||||||
// Log introspection endpoint availability for opaque token support
|
// Log introspection endpoint availability for opaque token support
|
||||||
|
|||||||
+30
-14
@@ -8,7 +8,6 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -485,8 +484,9 @@ func TestFirstRequestHandling(t *testing.T) {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
oidc := &TraefikOidc{
|
oidc := &TraefikOidc{
|
||||||
providerURL: server.URL,
|
providerURL: server.URL,
|
||||||
firstRequestStarted: 0,
|
firstRequestReceived: false,
|
||||||
|
firstRequestMutex: sync.Mutex{},
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: 5 * time.Second,
|
Timeout: 5 * time.Second,
|
||||||
},
|
},
|
||||||
@@ -508,13 +508,19 @@ func TestFirstRequestHandling(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simulate first request processing — single-firing via CAS.
|
// Simulate first request processing
|
||||||
if atomic.CompareAndSwapInt32(&oidc.firstRequestStarted, 0, 1) {
|
oidc.firstRequestMutex.Lock()
|
||||||
|
if !oidc.firstRequestReceived {
|
||||||
|
oidc.firstRequestReceived = true
|
||||||
|
oidc.firstRequestMutex.Unlock()
|
||||||
|
|
||||||
// This would normally be called asynchronously
|
// This would normally be called asynchronously
|
||||||
go func() {
|
go func() {
|
||||||
oidc.initializeMetadata(server.URL)
|
oidc.initializeMetadata(server.URL)
|
||||||
// initComplete is closed internally by initializeMetadata
|
// initComplete is closed internally by initializeMetadata
|
||||||
}()
|
}()
|
||||||
|
} else {
|
||||||
|
oidc.firstRequestMutex.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for initialization
|
// Wait for initialization
|
||||||
@@ -550,8 +556,9 @@ func TestFirstRequestHandling(t *testing.T) {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
oidc := &TraefikOidc{
|
oidc := &TraefikOidc{
|
||||||
providerURL: server.URL,
|
providerURL: server.URL,
|
||||||
firstRequestStarted: 0,
|
firstRequestReceived: false,
|
||||||
|
firstRequestMutex: sync.Mutex{},
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: 5 * time.Second,
|
Timeout: 5 * time.Second,
|
||||||
},
|
},
|
||||||
@@ -573,22 +580,31 @@ func TestFirstRequestHandling(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simulate multiple concurrent "first" requests — only one CAS winner
|
// Simulate multiple concurrent "first" requests
|
||||||
// fires the bootstrap path.
|
|
||||||
const numRequests = 10
|
const numRequests = 10
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
wg.Add(numRequests)
|
wg.Add(numRequests)
|
||||||
|
|
||||||
var initStarted int32
|
initStarted := 0
|
||||||
|
var initMu sync.Mutex
|
||||||
|
|
||||||
for i := 0; i < numRequests; i++ {
|
for i := 0; i < numRequests; i++ {
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
||||||
if atomic.CompareAndSwapInt32(&oidc.firstRequestStarted, 0, 1) {
|
oidc.firstRequestMutex.Lock()
|
||||||
atomic.AddInt32(&initStarted, 1)
|
if !oidc.firstRequestReceived {
|
||||||
|
oidc.firstRequestReceived = true
|
||||||
|
oidc.firstRequestMutex.Unlock()
|
||||||
|
|
||||||
|
initMu.Lock()
|
||||||
|
initStarted++
|
||||||
|
initMu.Unlock()
|
||||||
|
|
||||||
// Only one should actually start initialization
|
// Only one should actually start initialization
|
||||||
oidc.initializeMetadata(server.URL)
|
oidc.initializeMetadata(server.URL)
|
||||||
|
} else {
|
||||||
|
oidc.firstRequestMutex.Unlock()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
@@ -596,8 +612,8 @@ func TestFirstRequestHandling(t *testing.T) {
|
|||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
// Verify only one initialization was started
|
// Verify only one initialization was started
|
||||||
if atomic.LoadInt32(&initStarted) != 1 {
|
if initStarted != 1 {
|
||||||
t.Errorf("expected exactly 1 initialization, got %d", atomic.LoadInt32(&initStarted))
|
t.Errorf("expected exactly 1 initialization, got %d", initStarted)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The metadata endpoint might be called once or not at all depending on timing
|
// The metadata endpoint might be called once or not at all depending on timing
|
||||||
|
|||||||
+28
-28
@@ -61,8 +61,8 @@ func TestServeHTTP_ExcludedURLs(t *testing.T) {
|
|||||||
logger: NewLogger("debug"),
|
logger: NewLogger("debug"),
|
||||||
initComplete: make(chan struct{}),
|
initComplete: make(chan struct{}),
|
||||||
sessionManager: createTestSessionManager(t),
|
sessionManager: createTestSessionManager(t),
|
||||||
firstRequestStarted: 1,
|
firstRequestReceived: true,
|
||||||
metadataRefreshStartedAtomic: 1,
|
metadataRefreshStarted: true,
|
||||||
issuerURL: "https://provider.example.com", // Required for initialization check
|
issuerURL: "https://provider.example.com", // Required for initialization check
|
||||||
}
|
}
|
||||||
close(oidc.initComplete)
|
close(oidc.initComplete)
|
||||||
@@ -92,8 +92,8 @@ func TestServeHTTP_EventStream(t *testing.T) {
|
|||||||
logger: NewLogger("debug"),
|
logger: NewLogger("debug"),
|
||||||
initComplete: make(chan struct{}),
|
initComplete: make(chan struct{}),
|
||||||
sessionManager: sessionManager,
|
sessionManager: sessionManager,
|
||||||
firstRequestStarted: 1,
|
firstRequestReceived: true,
|
||||||
metadataRefreshStartedAtomic: 1,
|
metadataRefreshStarted: true,
|
||||||
issuerURL: "https://provider.example.com",
|
issuerURL: "https://provider.example.com",
|
||||||
}
|
}
|
||||||
close(oidc.initComplete)
|
close(oidc.initComplete)
|
||||||
@@ -175,8 +175,8 @@ func TestServeHTTP_WebSocketUpgrade(t *testing.T) {
|
|||||||
logger: NewLogger("debug"),
|
logger: NewLogger("debug"),
|
||||||
initComplete: make(chan struct{}),
|
initComplete: make(chan struct{}),
|
||||||
sessionManager: sessionManager,
|
sessionManager: sessionManager,
|
||||||
firstRequestStarted: 1,
|
firstRequestReceived: true,
|
||||||
metadataRefreshStartedAtomic: 1,
|
metadataRefreshStarted: true,
|
||||||
issuerURL: "https://provider.example.com",
|
issuerURL: "https://provider.example.com",
|
||||||
}
|
}
|
||||||
close(oidc.initComplete)
|
close(oidc.initComplete)
|
||||||
@@ -272,8 +272,8 @@ func TestServeHTTP_InitializationTimeout(t *testing.T) {
|
|||||||
logger: NewLogger("debug"),
|
logger: NewLogger("debug"),
|
||||||
initComplete: make(chan struct{}), // Never close this to simulate timeout
|
initComplete: make(chan struct{}), // Never close this to simulate timeout
|
||||||
sessionManager: createTestSessionManager(t),
|
sessionManager: createTestSessionManager(t),
|
||||||
firstRequestStarted: 1,
|
firstRequestReceived: true,
|
||||||
metadataRefreshStartedAtomic: 1,
|
metadataRefreshStarted: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/protected", nil)
|
req := httptest.NewRequest("GET", "/protected", nil)
|
||||||
@@ -307,8 +307,8 @@ func TestServeHTTP_InitializationTimeout(t *testing.T) {
|
|||||||
logger: NewLogger("debug"),
|
logger: NewLogger("debug"),
|
||||||
initComplete: make(chan struct{}),
|
initComplete: make(chan struct{}),
|
||||||
sessionManager: createTestSessionManager(t),
|
sessionManager: createTestSessionManager(t),
|
||||||
firstRequestStarted: 1,
|
firstRequestReceived: true,
|
||||||
metadataRefreshStartedAtomic: 1,
|
metadataRefreshStarted: true,
|
||||||
issuerURL: "https://provider.example.com",
|
issuerURL: "https://provider.example.com",
|
||||||
redirURLPath: "/callback",
|
redirURLPath: "/callback",
|
||||||
logoutURLPath: "/logout",
|
logoutURLPath: "/logout",
|
||||||
@@ -337,8 +337,8 @@ func TestServeHTTP_CallbackAndLogout(t *testing.T) {
|
|||||||
logger: NewLogger("debug"),
|
logger: NewLogger("debug"),
|
||||||
initComplete: make(chan struct{}),
|
initComplete: make(chan struct{}),
|
||||||
sessionManager: createTestSessionManager(t),
|
sessionManager: createTestSessionManager(t),
|
||||||
firstRequestStarted: 1,
|
firstRequestReceived: true,
|
||||||
metadataRefreshStartedAtomic: 1,
|
metadataRefreshStarted: true,
|
||||||
issuerURL: "https://provider.example.com",
|
issuerURL: "https://provider.example.com",
|
||||||
redirURLPath: "/callback",
|
redirURLPath: "/callback",
|
||||||
logoutURLPath: "/logout",
|
logoutURLPath: "/logout",
|
||||||
@@ -367,8 +367,8 @@ func TestServeHTTP_CallbackAndLogout(t *testing.T) {
|
|||||||
logger: NewLogger("debug"),
|
logger: NewLogger("debug"),
|
||||||
initComplete: make(chan struct{}),
|
initComplete: make(chan struct{}),
|
||||||
sessionManager: createTestSessionManager(t),
|
sessionManager: createTestSessionManager(t),
|
||||||
firstRequestStarted: 1,
|
firstRequestReceived: true,
|
||||||
metadataRefreshStartedAtomic: 1,
|
metadataRefreshStarted: true,
|
||||||
issuerURL: "https://provider.example.com",
|
issuerURL: "https://provider.example.com",
|
||||||
redirURLPath: "/callback",
|
redirURLPath: "/callback",
|
||||||
logoutURLPath: "/logout",
|
logoutURLPath: "/logout",
|
||||||
@@ -740,8 +740,8 @@ func TestMinimalHeaders(t *testing.T) {
|
|||||||
logger: NewLogger("debug"),
|
logger: NewLogger("debug"),
|
||||||
initComplete: make(chan struct{}),
|
initComplete: make(chan struct{}),
|
||||||
sessionManager: sessionManager,
|
sessionManager: sessionManager,
|
||||||
firstRequestStarted: 1,
|
firstRequestReceived: true,
|
||||||
metadataRefreshStartedAtomic: 1,
|
metadataRefreshStarted: true,
|
||||||
issuerURL: "https://provider.example.com",
|
issuerURL: "https://provider.example.com",
|
||||||
minimalHeaders: tt.minimalHeaders,
|
minimalHeaders: tt.minimalHeaders,
|
||||||
extractClaimsFunc: func(token string) (map[string]interface{}, error) {
|
extractClaimsFunc: func(token string) (map[string]interface{}, error) {
|
||||||
@@ -817,8 +817,8 @@ func TestMinimalHeaders_TokenHeaderNotSet(t *testing.T) {
|
|||||||
logger: NewLogger("debug"),
|
logger: NewLogger("debug"),
|
||||||
initComplete: make(chan struct{}),
|
initComplete: make(chan struct{}),
|
||||||
sessionManager: sessionManager,
|
sessionManager: sessionManager,
|
||||||
firstRequestStarted: 1,
|
firstRequestReceived: true,
|
||||||
metadataRefreshStartedAtomic: 1,
|
metadataRefreshStarted: true,
|
||||||
issuerURL: "https://provider.example.com",
|
issuerURL: "https://provider.example.com",
|
||||||
minimalHeaders: true, // Enable minimal headers
|
minimalHeaders: true, // Enable minimal headers
|
||||||
extractClaimsFunc: func(token string) (map[string]interface{}, error) {
|
extractClaimsFunc: func(token string) (map[string]interface{}, error) {
|
||||||
@@ -903,8 +903,8 @@ func TestStripAuthCookies(t *testing.T) {
|
|||||||
logger: NewLogger("debug"),
|
logger: NewLogger("debug"),
|
||||||
initComplete: make(chan struct{}),
|
initComplete: make(chan struct{}),
|
||||||
sessionManager: sessionManager,
|
sessionManager: sessionManager,
|
||||||
firstRequestStarted: 1,
|
firstRequestReceived: true,
|
||||||
metadataRefreshStartedAtomic: 1,
|
metadataRefreshStarted: true,
|
||||||
issuerURL: "https://provider.example.com",
|
issuerURL: "https://provider.example.com",
|
||||||
stripAuthCookies: tt.stripAuthCookies,
|
stripAuthCookies: tt.stripAuthCookies,
|
||||||
extractClaimsFunc: func(token string) (map[string]interface{}, error) {
|
extractClaimsFunc: func(token string) (map[string]interface{}, error) {
|
||||||
@@ -987,8 +987,8 @@ func TestStripAuthCookies_NoCookies(t *testing.T) {
|
|||||||
logger: NewLogger("debug"),
|
logger: NewLogger("debug"),
|
||||||
initComplete: make(chan struct{}),
|
initComplete: make(chan struct{}),
|
||||||
sessionManager: sessionManager,
|
sessionManager: sessionManager,
|
||||||
firstRequestStarted: 1,
|
firstRequestReceived: true,
|
||||||
metadataRefreshStartedAtomic: 1,
|
metadataRefreshStarted: true,
|
||||||
issuerURL: "https://provider.example.com",
|
issuerURL: "https://provider.example.com",
|
||||||
stripAuthCookies: true,
|
stripAuthCookies: true,
|
||||||
extractClaimsFunc: func(token string) (map[string]interface{}, error) {
|
extractClaimsFunc: func(token string) (map[string]interface{}, error) {
|
||||||
@@ -1034,8 +1034,8 @@ func TestStripAuthCookies_OnlyOIDCCookies(t *testing.T) {
|
|||||||
logger: NewLogger("debug"),
|
logger: NewLogger("debug"),
|
||||||
initComplete: make(chan struct{}),
|
initComplete: make(chan struct{}),
|
||||||
sessionManager: sessionManager,
|
sessionManager: sessionManager,
|
||||||
firstRequestStarted: 1,
|
firstRequestReceived: true,
|
||||||
metadataRefreshStartedAtomic: 1,
|
metadataRefreshStarted: true,
|
||||||
issuerURL: "https://provider.example.com",
|
issuerURL: "https://provider.example.com",
|
||||||
stripAuthCookies: true,
|
stripAuthCookies: true,
|
||||||
extractClaimsFunc: func(token string) (map[string]interface{}, error) {
|
extractClaimsFunc: func(token string) (map[string]interface{}, error) {
|
||||||
@@ -1085,8 +1085,8 @@ func TestStripAuthCookies_OnlyAppCookies(t *testing.T) {
|
|||||||
logger: NewLogger("debug"),
|
logger: NewLogger("debug"),
|
||||||
initComplete: make(chan struct{}),
|
initComplete: make(chan struct{}),
|
||||||
sessionManager: sessionManager,
|
sessionManager: sessionManager,
|
||||||
firstRequestStarted: 1,
|
firstRequestReceived: true,
|
||||||
metadataRefreshStartedAtomic: 1,
|
metadataRefreshStarted: true,
|
||||||
issuerURL: "https://provider.example.com",
|
issuerURL: "https://provider.example.com",
|
||||||
stripAuthCookies: true,
|
stripAuthCookies: true,
|
||||||
extractClaimsFunc: func(token string) (map[string]interface{}, error) {
|
extractClaimsFunc: func(token string) (map[string]interface{}, error) {
|
||||||
@@ -1148,8 +1148,8 @@ func TestStripAuthCookies_CustomPrefix(t *testing.T) {
|
|||||||
logger: NewLogger("debug"),
|
logger: NewLogger("debug"),
|
||||||
initComplete: make(chan struct{}),
|
initComplete: make(chan struct{}),
|
||||||
sessionManager: sm,
|
sessionManager: sm,
|
||||||
firstRequestStarted: 1,
|
firstRequestReceived: true,
|
||||||
metadataRefreshStartedAtomic: 1,
|
metadataRefreshStarted: true,
|
||||||
issuerURL: "https://provider.example.com",
|
issuerURL: "https://provider.example.com",
|
||||||
stripAuthCookies: true,
|
stripAuthCookies: true,
|
||||||
extractClaimsFunc: func(token string) (map[string]interface{}, error) {
|
extractClaimsFunc: func(token string) (map[string]interface{}, error) {
|
||||||
|
|||||||
+4
-4
@@ -16,7 +16,6 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -2686,9 +2685,10 @@ func TestMetadataRecoveryOnProviderFailure(t *testing.T) {
|
|||||||
providerAvailable = true
|
providerAvailable = true
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
|
|
||||||
// Reset the retry timer to allow immediate retry. The field is atomic
|
// Reset the retry timer to allow immediate retry
|
||||||
// now, so no lock is needed.
|
m.metadataRetryMutex.Lock()
|
||||||
atomic.StoreInt64(&m.lastMetadataRetryNano, 0)
|
m.lastMetadataRetryTime = time.Time{} // Reset to zero time
|
||||||
|
m.metadataRetryMutex.Unlock()
|
||||||
|
|
||||||
// Second request should trigger recovery attempt
|
// Second request should trigger recovery attempt
|
||||||
req2 := httptest.NewRequest("GET", "/protected", nil)
|
req2 := httptest.NewRequest("GET", "/protected", nil)
|
||||||
|
|||||||
+23
-141
@@ -8,7 +8,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/lukaszraczylo/traefikoidc/internal/utils"
|
"github.com/lukaszraczylo/traefikoidc/internal/utils"
|
||||||
@@ -146,20 +145,19 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !strings.HasPrefix(req.URL.Path, "/health") {
|
if !strings.HasPrefix(req.URL.Path, "/health") {
|
||||||
// Lock-free one-shot bootstrap. The previous firstRequestMutex.Lock()
|
t.firstRequestMutex.Lock()
|
||||||
// fired on EVERY non-health request forever (even after the boolean
|
if !t.firstRequestReceived {
|
||||||
// flipped true), which under Yaegi added a per-request serialization
|
t.firstRequestReceived = true
|
||||||
// point. CAS gives single-firing semantics with zero steady-state cost.
|
|
||||||
if atomic.CompareAndSwapInt32(&t.firstRequestStarted, 0, 1) {
|
|
||||||
t.logger.Debug("Starting background tasks on first request")
|
t.logger.Debug("Starting background tasks on first request")
|
||||||
t.startTokenCleanup()
|
t.startTokenCleanup()
|
||||||
|
|
||||||
if t.providerURL != "" &&
|
if !t.metadataRefreshStarted && t.providerURL != "" {
|
||||||
atomic.CompareAndSwapInt32(&t.metadataRefreshStartedAtomic, 0, 1) {
|
t.metadataRefreshStarted = true
|
||||||
// Metadata refresh is handled by singleton resource manager
|
// Metadata refresh is handled by singleton resource manager
|
||||||
t.startMetadataRefresh(t.providerURL)
|
t.startMetadataRefresh(t.providerURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
t.firstRequestMutex.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Evaluate auth-bypass once, before waiting for initialization. Excluded
|
// Evaluate auth-bypass once, before waiting for initialization. Excluded
|
||||||
@@ -209,31 +207,20 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
|||||||
|
|
||||||
select {
|
select {
|
||||||
case <-t.initComplete:
|
case <-t.initComplete:
|
||||||
// Read issuerURL via atomic snapshot when available — replaces the
|
// Read issuerURL with RLock
|
||||||
// metadataMu.RLock that previously fired on every non-bypass request.
|
t.metadataMu.RLock()
|
||||||
// Under Yaegi each RLock acquisition costs 1-5ms of interpreter
|
issuerURL := t.issuerURL
|
||||||
// dispatch; the snapshot is a single atomic.Value.Load. Falls back
|
t.metadataMu.RUnlock()
|
||||||
// to the legacy field+RLock for paths that haven't published a
|
|
||||||
// snapshot yet (notably some test setups that initialize the struct
|
|
||||||
// fields directly).
|
|
||||||
var issuerURL string
|
|
||||||
if snap := t.metadataSnap(); snap != nil {
|
|
||||||
issuerURL = snap.IssuerURL
|
|
||||||
} else {
|
|
||||||
t.metadataMu.RLock()
|
|
||||||
issuerURL = t.issuerURL
|
|
||||||
t.metadataMu.RUnlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
if issuerURL == "" {
|
if issuerURL == "" {
|
||||||
// Provider metadata initialization failed - try to recover.
|
// Provider metadata initialization failed - try to recover
|
||||||
// Retry every 30 seconds to allow automatic recovery. Lock-free
|
// Retry every 30 seconds to allow automatic recovery when provider comes back online
|
||||||
// throttle via CAS on lastMetadataRetryNano: one goroutine wins
|
t.metadataRetryMutex.Lock()
|
||||||
// the window, others see shouldRetry=false.
|
shouldRetry := time.Since(t.lastMetadataRetryTime) >= 30*time.Second
|
||||||
nowNano := time.Now().UnixNano()
|
if shouldRetry {
|
||||||
last := atomic.LoadInt64(&t.lastMetadataRetryNano)
|
t.lastMetadataRetryTime = time.Now()
|
||||||
shouldRetry := time.Duration(nowNano-last) >= 30*time.Second &&
|
}
|
||||||
atomic.CompareAndSwapInt64(&t.lastMetadataRetryNano, last, nowNano)
|
t.metadataRetryMutex.Unlock()
|
||||||
|
|
||||||
if shouldRetry && t.providerURL != "" {
|
if shouldRetry && t.providerURL != "" {
|
||||||
t.logger.Info("Attempting to recover OIDC provider metadata...")
|
t.logger.Info("Attempting to recover OIDC provider metadata...")
|
||||||
@@ -311,19 +298,6 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
|||||||
host := utils.DetermineHost(req)
|
host := utils.DetermineHost(req)
|
||||||
redirectURL := buildFullURL(scheme, host, t.redirURLPath)
|
redirectURL := buildFullURL(scheme, host, t.redirURLPath)
|
||||||
|
|
||||||
// Capture per-request state: one RLock on sd.sessionMutex covers all the
|
|
||||||
// getter values the handler chain needs (instead of 5-7 separate
|
|
||||||
// session.GetX() calls each acquiring their own RLock under Yaegi).
|
|
||||||
// metadataSnap is also stored once so downstream handlers don't repeat
|
|
||||||
// the atomic.Value.Load.
|
|
||||||
rs := (&requestState{
|
|
||||||
scheme: scheme,
|
|
||||||
host: host,
|
|
||||||
redirectURL: redirectURL,
|
|
||||||
next: t.next,
|
|
||||||
metadata: t.metadataSnap(),
|
|
||||||
}).captureSession(session)
|
|
||||||
|
|
||||||
// Check if the current request is the OIDC callback
|
// Check if the current request is the OIDC callback
|
||||||
t.logger.Debugf("Checking callback URL match: request_path=%q, configured_callback=%q", req.URL.Path, t.redirURLPath)
|
t.logger.Debugf("Checking callback URL match: request_path=%q, configured_callback=%q", req.URL.Path, t.redirURLPath)
|
||||||
if req.URL.Path == t.redirURLPath {
|
if req.URL.Path == t.redirURLPath {
|
||||||
@@ -333,10 +307,7 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
|||||||
}
|
}
|
||||||
t.logger.Debugf("Callback URL did not match (request_path=%q != configured=%q), continuing auth flow", req.URL.Path, t.redirURLPath)
|
t.logger.Debugf("Callback URL did not match (request_path=%q != configured=%q), continuing auth flow", req.URL.Path, t.redirURLPath)
|
||||||
|
|
||||||
// Token validation reads session via the captured snapshot — saves ~21
|
authenticated, needsRefresh, expired := t.isUserAuthenticated(session)
|
||||||
// sd.sessionMutex.RLock acquisitions (Yaegi-dispatched, ~1-5ms each)
|
|
||||||
// across the validation path.
|
|
||||||
authenticated, needsRefresh, expired := t.isUserAuthenticatedRS(rs)
|
|
||||||
|
|
||||||
if expired {
|
if expired {
|
||||||
t.logger.Debug("Session token is definitively expired or invalid, initiating re-auth")
|
t.logger.Debug("Session token is definitively expired or invalid, initiating re-auth")
|
||||||
@@ -344,7 +315,7 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userIdentifier := rs.userIdentifier
|
userIdentifier := session.GetUserIdentifier()
|
||||||
// User authorization check
|
// User authorization check
|
||||||
if authenticated && userIdentifier != "" {
|
if authenticated && userIdentifier != "" {
|
||||||
if !t.isAllowedUser(userIdentifier) {
|
if !t.isAllowedUser(userIdentifier) {
|
||||||
@@ -361,11 +332,11 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
|||||||
// methods (validateAzureTokens/validateStandardTokens) before reaching this point.
|
// methods (validateAzureTokens/validateStandardTokens) before reaching this point.
|
||||||
// Redundant validation here was causing issues with Azure AD tokens that have
|
// Redundant validation here was causing issues with Azure AD tokens that have
|
||||||
// JWT format but unverifiable signatures. See issue #89.
|
// JWT format but unverifiable signatures. See issue #89.
|
||||||
t.processAuthorizedRequestRS(rw, req, rs)
|
t.processAuthorizedRequest(rw, req, session, redirectURL)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshTokenPresent := rs.refreshToken != ""
|
refreshTokenPresent := session.GetRefreshToken() != ""
|
||||||
|
|
||||||
// Decide whether to answer with 401 instead of a redirect. AJAX requests
|
// Decide whether to answer with 401 instead of a redirect. AJAX requests
|
||||||
// cannot follow a 302 into an IdP, and sub-resource loads (script/image/
|
// cannot follow a 302 into an IdP, and sub-resource loads (script/image/
|
||||||
@@ -472,95 +443,6 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
|||||||
// - req: The HTTP request to process.
|
// - req: The HTTP request to process.
|
||||||
// - session: The user's session data containing tokens and claims.
|
// - session: The user's session data containing tokens and claims.
|
||||||
// - redirectURL: The callback URL for re-authentication if needed.
|
// - redirectURL: The callback URL for re-authentication if needed.
|
||||||
// processAuthorizedRequestRS is the requestState-aware variant of
|
|
||||||
// processAuthorizedRequest. It reads SessionData fields from the captured
|
|
||||||
// snapshot in rs instead of calling session.GetX() (each of which acquires
|
|
||||||
// sd.sessionMutex.RLock — under Yaegi every RLock pays ~1-5ms of interpreter
|
|
||||||
// dispatch). Only session-mutating operations (Save, ResetRedirectCount,
|
|
||||||
// Clear, IsDirty) still go through the session pointer because those write
|
|
||||||
// state and have no snapshot.
|
|
||||||
func (t *TraefikOidc) processAuthorizedRequestRS(rw http.ResponseWriter, req *http.Request, rs *requestState) {
|
|
||||||
session := rs.session
|
|
||||||
redirectURL := rs.redirectURL
|
|
||||||
userIdentifier := rs.userIdentifier
|
|
||||||
if userIdentifier == "" {
|
|
||||||
t.logger.Info("No user identifier found in session during final processing, initiating re-auth")
|
|
||||||
session.ResetRedirectCount()
|
|
||||||
t.defaultInitiateAuthentication(rw, req, session, redirectURL)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if session has been invalidated via backchannel or front-channel logout
|
|
||||||
idToken := rs.idToken
|
|
||||||
if t.enableBackchannelLogout || t.enableFrontchannelLogout {
|
|
||||||
if idToken != "" {
|
|
||||||
sid, sub, createdAt := t.extractSessionInfo(idToken)
|
|
||||||
if t.isSessionInvalidated(sid, sub, createdAt) {
|
|
||||||
t.logger.Infof("Session for user %s has been invalidated via IdP-initiated logout", userIdentifier)
|
|
||||||
if err := session.Clear(req, rw); err != nil {
|
|
||||||
t.logger.Errorf("Error clearing invalidated session: %v", err)
|
|
||||||
}
|
|
||||||
session.ResetRedirectCount()
|
|
||||||
t.defaultInitiateAuthentication(rw, req, session, redirectURL)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve ID-token claims at most once per request. SessionData caches
|
|
||||||
// the parsed claims keyed on the raw ID token.
|
|
||||||
var (
|
|
||||||
idClaims map[string]interface{}
|
|
||||||
idClaimsErr error
|
|
||||||
)
|
|
||||||
if idToken != "" {
|
|
||||||
idClaims, idClaimsErr = session.GetIDTokenClaims(t.extractClaimsFunc)
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
groupClaims map[string]interface{}
|
|
||||||
groupClaimsErr error
|
|
||||||
)
|
|
||||||
if idToken != "" {
|
|
||||||
groupClaims, groupClaimsErr = idClaims, idClaimsErr
|
|
||||||
} else if rs.accessToken != "" {
|
|
||||||
groupClaims, groupClaimsErr = t.extractClaimsFunc(rs.accessToken)
|
|
||||||
} else if len(t.allowedRolesAndGroups) > 0 {
|
|
||||||
t.logger.Error("No token available but roles/groups checks are required")
|
|
||||||
session.ResetRedirectCount()
|
|
||||||
t.defaultInitiateAuthentication(rw, req, session, redirectURL)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if groupClaimsErr != nil && len(t.allowedRolesAndGroups) > 0 {
|
|
||||||
t.logger.Errorf("Failed to extract claims for roles/groups check: %v", groupClaimsErr)
|
|
||||||
session.ResetRedirectCount()
|
|
||||||
t.defaultInitiateAuthentication(rw, req, session, redirectURL)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist any dirty session state BEFORE forwardAuthorized writes the
|
|
||||||
// response.
|
|
||||||
if session.IsDirty() {
|
|
||||||
if err := session.Save(req, rw); err != nil {
|
|
||||||
t.logger.Errorf("Failed to save session after processing headers: %v", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
t.logger.Debug("Session not dirty, skipping save in processAuthorizedRequest")
|
|
||||||
}
|
|
||||||
|
|
||||||
p := &principal{
|
|
||||||
Source: sourceSession,
|
|
||||||
Identifier: userIdentifier,
|
|
||||||
AccessToken: rs.accessToken,
|
|
||||||
IDToken: idToken,
|
|
||||||
RefreshToken: rs.refreshToken,
|
|
||||||
Claims: groupClaims,
|
|
||||||
}
|
|
||||||
|
|
||||||
t.forwardAuthorized(rw, req, p)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *TraefikOidc) processAuthorizedRequest(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) {
|
func (t *TraefikOidc) processAuthorizedRequest(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) {
|
||||||
userIdentifier := session.GetUserIdentifier()
|
userIdentifier := session.GetUserIdentifier()
|
||||||
if userIdentifier == "" {
|
if userIdentifier == "" {
|
||||||
@@ -721,7 +603,7 @@ func (t *TraefikOidc) forwardAuthorized(rw http.ResponseWriter, req *http.Reques
|
|||||||
|
|
||||||
// When minimalHeaders is enabled, skip extra headers to prevent 431 errors
|
// When minimalHeaders is enabled, skip extra headers to prevent 431 errors
|
||||||
if !t.minimalHeaders {
|
if !t.minimalHeaders {
|
||||||
req.Header.Set("X-Auth-Request-Redirect", req.URL.RequestURI())
|
req.Header.Set("X-Auth-Request-Redirect", t.originalRequestURI(req))
|
||||||
req.Header.Set("X-Auth-Request-User", p.Identifier)
|
req.Header.Set("X-Auth-Request-User", p.Identifier)
|
||||||
if p.IDToken != "" {
|
if p.IDToken != "" {
|
||||||
req.Header.Set("X-Auth-Request-Token", p.IDToken)
|
req.Header.Set("X-Auth-Request-Token", p.IDToken)
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ func TestMiddlewareContextCancellation(t *testing.T) {
|
|||||||
logger: NewLogger("debug"),
|
logger: NewLogger("debug"),
|
||||||
initComplete: make(chan struct{}), // Never close to simulate waiting
|
initComplete: make(chan struct{}), // Never close to simulate waiting
|
||||||
sessionManager: createTestSessionManager(t),
|
sessionManager: createTestSessionManager(t),
|
||||||
firstRequestStarted: 1,
|
firstRequestReceived: true,
|
||||||
metadataRefreshStartedAtomic: 1,
|
metadataRefreshStarted: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create request with canceled context
|
// Create request with canceled context
|
||||||
@@ -39,8 +39,8 @@ func TestMiddlewareSessionErrorRecovery(t *testing.T) {
|
|||||||
logger: NewLogger("debug"),
|
logger: NewLogger("debug"),
|
||||||
initComplete: make(chan struct{}),
|
initComplete: make(chan struct{}),
|
||||||
sessionManager: createTestSessionManager(t),
|
sessionManager: createTestSessionManager(t),
|
||||||
firstRequestStarted: 1,
|
firstRequestReceived: true,
|
||||||
metadataRefreshStartedAtomic: 1,
|
metadataRefreshStarted: true,
|
||||||
issuerURL: "https://provider.example.com",
|
issuerURL: "https://provider.example.com",
|
||||||
redirURLPath: "/callback",
|
redirURLPath: "/callback",
|
||||||
logoutURLPath: "/logout",
|
logoutURLPath: "/logout",
|
||||||
@@ -73,8 +73,8 @@ func TestMiddlewareAJAXRequestHandling(t *testing.T) {
|
|||||||
logger: NewLogger("debug"),
|
logger: NewLogger("debug"),
|
||||||
initComplete: make(chan struct{}),
|
initComplete: make(chan struct{}),
|
||||||
sessionManager: createTestSessionManager(t),
|
sessionManager: createTestSessionManager(t),
|
||||||
firstRequestStarted: 1,
|
firstRequestReceived: true,
|
||||||
metadataRefreshStartedAtomic: 1,
|
metadataRefreshStarted: true,
|
||||||
issuerURL: "https://provider.example.com",
|
issuerURL: "https://provider.example.com",
|
||||||
redirURLPath: "/callback",
|
redirURLPath: "/callback",
|
||||||
logoutURLPath: "/logout",
|
logoutURLPath: "/logout",
|
||||||
@@ -102,8 +102,8 @@ func TestLogoutWorksWithoutOIDCInitialization(t *testing.T) {
|
|||||||
logger: NewLogger("debug"),
|
logger: NewLogger("debug"),
|
||||||
initComplete: make(chan struct{}), // Never close to simulate provider unavailable
|
initComplete: make(chan struct{}), // Never close to simulate provider unavailable
|
||||||
sessionManager: createTestSessionManager(t),
|
sessionManager: createTestSessionManager(t),
|
||||||
firstRequestStarted: 1,
|
firstRequestReceived: true,
|
||||||
metadataRefreshStartedAtomic: 1,
|
metadataRefreshStarted: true,
|
||||||
logoutURLPath: "/logout",
|
logoutURLPath: "/logout",
|
||||||
postLogoutRedirectURI: "/",
|
postLogoutRedirectURI: "/",
|
||||||
forceHTTPS: false,
|
forceHTTPS: false,
|
||||||
@@ -142,8 +142,8 @@ func TestMiddlewareDomainRestrictions(t *testing.T) {
|
|||||||
logger: NewLogger("debug"),
|
logger: NewLogger("debug"),
|
||||||
initComplete: make(chan struct{}),
|
initComplete: make(chan struct{}),
|
||||||
sessionManager: sessionManager,
|
sessionManager: sessionManager,
|
||||||
firstRequestStarted: 1,
|
firstRequestReceived: true,
|
||||||
metadataRefreshStartedAtomic: 1,
|
metadataRefreshStarted: true,
|
||||||
issuerURL: "https://provider.example.com",
|
issuerURL: "https://provider.example.com",
|
||||||
redirURLPath: "/callback",
|
redirURLPath: "/callback",
|
||||||
logoutURLPath: "/logout",
|
logoutURLPath: "/logout",
|
||||||
@@ -187,8 +187,8 @@ func TestMiddlewareDomainRestrictions(t *testing.T) {
|
|||||||
logger: NewLogger("debug"),
|
logger: NewLogger("debug"),
|
||||||
initComplete: make(chan struct{}),
|
initComplete: make(chan struct{}),
|
||||||
sessionManager: sessionManager,
|
sessionManager: sessionManager,
|
||||||
firstRequestStarted: 1,
|
firstRequestReceived: true,
|
||||||
metadataRefreshStartedAtomic: 1,
|
metadataRefreshStarted: true,
|
||||||
issuerURL: "https://provider.example.com",
|
issuerURL: "https://provider.example.com",
|
||||||
redirURLPath: "/callback",
|
redirURLPath: "/callback",
|
||||||
logoutURLPath: "/logout",
|
logoutURLPath: "/logout",
|
||||||
@@ -236,8 +236,8 @@ func TestMiddlewareOpaqueTokenHandling(t *testing.T) {
|
|||||||
logger: NewLogger("debug"),
|
logger: NewLogger("debug"),
|
||||||
initComplete: make(chan struct{}),
|
initComplete: make(chan struct{}),
|
||||||
sessionManager: sessionManager,
|
sessionManager: sessionManager,
|
||||||
firstRequestStarted: 1,
|
firstRequestReceived: true,
|
||||||
metadataRefreshStartedAtomic: 1,
|
metadataRefreshStarted: true,
|
||||||
issuerURL: "https://provider.example.com",
|
issuerURL: "https://provider.example.com",
|
||||||
redirURLPath: "/callback",
|
redirURLPath: "/callback",
|
||||||
logoutURLPath: "/logout",
|
logoutURLPath: "/logout",
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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()
|
||||||
|
u := t.authURL
|
||||||
|
t.metadataMu.RUnlock()
|
||||||
|
return u != ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+201
-341
@@ -15,28 +15,18 @@ import (
|
|||||||
// It implements request coalescing, rate limiting, and circuit breaking
|
// It implements request coalescing, rate limiting, and circuit breaking
|
||||||
// specifically for token refresh operations.
|
// specifically for token refresh operations.
|
||||||
type RefreshCoordinator struct {
|
type RefreshCoordinator struct {
|
||||||
// inFlightRefreshes maps tokenHash -> *refreshOperation. sync.Map is used
|
inFlightRefreshes map[string]*refreshOperation
|
||||||
// instead of a plain map + RWMutex so concurrent refreshes do not
|
cleanupTimers map[string]*time.Timer
|
||||||
// serialize on a single global lock. Under Yaegi the previous
|
sessionRefreshAttempts map[string]*refreshAttemptTracker
|
||||||
// refreshMutex.Lock() was held for tens of milliseconds per request due
|
|
||||||
// to interpreter overhead on the work inside the critical section,
|
|
||||||
// causing dozens of goroutines to stack up on it and pin one CPU core.
|
|
||||||
inFlightRefreshes sync.Map
|
|
||||||
// sessionRefreshAttempts maps sessionID -> *refreshAttemptTracker.
|
|
||||||
// sync.Map + atomic tracker fields means isInCooldown/recordRefreshAttempt/
|
|
||||||
// recordRefreshSuccess/recordRefreshFailure are lock-free. Previously
|
|
||||||
// these used attemptsMutex sync.RWMutex; under Yaegi every Lock() acquisition
|
|
||||||
// adds 10-50ms of dispatch overhead, and they were called twice per leader
|
|
||||||
// request (once for recordRefreshAttempt, once for isInCooldown). That
|
|
||||||
// serializing pattern caused the v1.0.15 death spiral after v1.0.14
|
|
||||||
// removed the refreshMutex (same architectural shape, different mutex).
|
|
||||||
sessionRefreshAttempts sync.Map
|
|
||||||
circuitBreaker *RefreshCircuitBreaker
|
circuitBreaker *RefreshCircuitBreaker
|
||||||
metrics *RefreshMetrics
|
metrics *RefreshMetrics
|
||||||
logger *Logger
|
logger *Logger
|
||||||
stopChan chan struct{}
|
stopChan chan struct{}
|
||||||
config RefreshCoordinatorConfig
|
config RefreshCoordinatorConfig
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
|
attemptsMutex sync.RWMutex
|
||||||
|
refreshMutex sync.RWMutex
|
||||||
|
cleanupTimerMu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefreshCoordinatorConfig configures the refresh coordinator behavior
|
// RefreshCoordinatorConfig configures the refresh coordinator behavior
|
||||||
@@ -94,46 +84,14 @@ type refreshResult struct {
|
|||||||
fromCache bool
|
fromCache bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// attemptState is the immutable snapshot of a session's refresh-attempt
|
// refreshAttemptTracker tracks refresh attempts for a session
|
||||||
// state. Lives behind refreshAttemptTracker.state (atomic.Value). Every
|
type refreshAttemptTracker struct {
|
||||||
// transition (record, success, failure, window-reset, cooldown-enter,
|
lastAttemptTime time.Time
|
||||||
// cooldown-exit) constructs a fresh attemptState and publishes it via
|
windowStartTime time.Time
|
||||||
// CompareAndSwap so the entire field set is updated together.
|
cooldownEndTime time.Time
|
||||||
//
|
|
||||||
// Per-field atomic.Load/Store (the previous v1.0.15 design) had a benign
|
|
||||||
// but observable hazard: the cooldown-exit reset wrote cooldownEndNano = 0
|
|
||||||
// first, then separately stored attempts = 1 and windowStartNano = now.
|
|
||||||
// A concurrent isInCooldown call could see cooldownEndNano = 0 (reset
|
|
||||||
// just completed) with attempts still at MaxRefreshAttempts, triggering
|
|
||||||
// a fresh cooldown immediately. The snapshot approach eliminates the
|
|
||||||
// intermediate state entirely.
|
|
||||||
type attemptState struct {
|
|
||||||
lastAttemptNano int64 // UnixNano of last attempt
|
|
||||||
windowStartNano int64 // UnixNano of attempt-window start
|
|
||||||
cooldownEndNano int64 // UnixNano; 0 = not in cooldown
|
|
||||||
attempts int32
|
attempts int32
|
||||||
consecutiveFailures int32
|
consecutiveFailures int32
|
||||||
}
|
inCooldown bool
|
||||||
|
|
||||||
// refreshAttemptTracker tracks refresh attempts for a session via a single
|
|
||||||
// atomic.Value holding a *attemptState pointer. Readers do exactly one Load.
|
|
||||||
// Writers do Load → construct new → CompareAndSwap (retry on conflict).
|
|
||||||
// Under Yaegi this collapses 3-4 per-field atomic dispatches into one Load,
|
|
||||||
// and eliminates the cross-field race in the window-reset path.
|
|
||||||
type refreshAttemptTracker struct {
|
|
||||||
state atomic.Value // *attemptState
|
|
||||||
}
|
|
||||||
|
|
||||||
// stateOf returns the current attemptState, or a zero-value snapshot if none
|
|
||||||
// has been published yet. The empty snapshot represents "no attempts recorded".
|
|
||||||
func (t *refreshAttemptTracker) stateOf() *attemptState {
|
|
||||||
if v := t.state.Load(); v != nil {
|
|
||||||
s, _ := v.(*attemptState)
|
|
||||||
if s != nil {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &attemptState{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefreshMetrics tracks coordinator performance metrics
|
// RefreshMetrics tracks coordinator performance metrics
|
||||||
@@ -148,18 +106,14 @@ type RefreshMetrics struct {
|
|||||||
currentInFlightRefreshes int32
|
currentInFlightRefreshes int32
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefreshCircuitBreaker implements a circuit breaker specifically for refresh
|
// RefreshCircuitBreaker implements a circuit breaker specifically for refresh operations
|
||||||
// operations. All mutable fields are atomic so AllowRequest/RecordSuccess/
|
|
||||||
// RecordFailure run without any mutex. The previous sync.RWMutex.RLock() was
|
|
||||||
// taken on every CoordinateRefresh — under Yaegi this added 10-50ms of
|
|
||||||
// interpreter dispatch per call, which compounded with attemptsMutex to keep
|
|
||||||
// the pod's single CPU core saturated.
|
|
||||||
type RefreshCircuitBreaker struct {
|
type RefreshCircuitBreaker struct {
|
||||||
lastFailureNano int64 // atomic, UnixNano of most recent failure
|
lastFailureTime time.Time
|
||||||
lastSuccessNano int64 // atomic, UnixNano of most recent success
|
lastSuccessTime time.Time
|
||||||
config RefreshCircuitBreakerConfig
|
config RefreshCircuitBreakerConfig
|
||||||
state int32 // atomic: 0=closed, 1=open, 2=half-open
|
mutex sync.RWMutex
|
||||||
failures int32 // atomic
|
state int32
|
||||||
|
failures int32
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefreshCircuitBreakerConfig configures the refresh circuit breaker
|
// RefreshCircuitBreakerConfig configures the refresh circuit breaker
|
||||||
@@ -176,12 +130,13 @@ func NewRefreshCoordinator(config RefreshCoordinatorConfig, logger *Logger) *Ref
|
|||||||
}
|
}
|
||||||
|
|
||||||
rc := &RefreshCoordinator{
|
rc := &RefreshCoordinator{
|
||||||
// inFlightRefreshes and sessionRefreshAttempts are both sync.Map;
|
inFlightRefreshes: make(map[string]*refreshOperation),
|
||||||
// their zero values are ready to use.
|
sessionRefreshAttempts: make(map[string]*refreshAttemptTracker),
|
||||||
config: config,
|
config: config,
|
||||||
metrics: &RefreshMetrics{},
|
metrics: &RefreshMetrics{},
|
||||||
logger: logger,
|
logger: logger,
|
||||||
stopChan: make(chan struct{}),
|
stopChan: make(chan struct{}),
|
||||||
|
cleanupTimers: make(map[string]*time.Timer),
|
||||||
circuitBreaker: &RefreshCircuitBreaker{
|
circuitBreaker: &RefreshCircuitBreaker{
|
||||||
config: RefreshCircuitBreakerConfig{
|
config: RefreshCircuitBreakerConfig{
|
||||||
MaxFailures: 3,
|
MaxFailures: 3,
|
||||||
@@ -272,28 +227,13 @@ func (rc *RefreshCoordinator) getOrCreateOperation(
|
|||||||
tokenHash string,
|
tokenHash string,
|
||||||
refreshToken string,
|
refreshToken string,
|
||||||
) (*refreshOperation, bool, error) {
|
) (*refreshOperation, bool, error) {
|
||||||
// Speculatively construct the operation we WOULD register if we win the
|
rc.refreshMutex.Lock()
|
||||||
// race. Allocating here keeps the LoadOrStore call below atomic and
|
defer rc.refreshMutex.Unlock()
|
||||||
// avoids any global lock — under Yaegi the previous map+RWMutex design
|
|
||||||
// held the write lock long enough (tens of ms per call) that concurrent
|
|
||||||
// refreshes on the same coordinator serialized into a queue that grew
|
|
||||||
// without bound. See struct comment on inFlightRefreshes.
|
|
||||||
candidate := &refreshOperation{
|
|
||||||
refreshToken: refreshToken,
|
|
||||||
done: make(chan struct{}),
|
|
||||||
startTime: time.Now(),
|
|
||||||
waiterCount: 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
if existing, loaded := rc.inFlightRefreshes.LoadOrStore(tokenHash, candidate); loaded {
|
// Check for existing operation while holding the lock
|
||||||
existingOp, ok := existing.(*refreshOperation)
|
if existingOp, exists := rc.inFlightRefreshes[tokenHash]; exists {
|
||||||
if !ok {
|
|
||||||
// Defensive: anything stored here is always *refreshOperation, but
|
|
||||||
// keep the typed assert so a programming error elsewhere doesn't
|
|
||||||
// surface as a confusing panic in an interpreter frame.
|
|
||||||
return nil, false, fmt.Errorf("inFlightRefreshes corrupt: unexpected type %T", existing)
|
|
||||||
}
|
|
||||||
if existingOp.refreshToken == refreshToken {
|
if existingOp.refreshToken == refreshToken {
|
||||||
|
// Join existing operation
|
||||||
atomic.AddInt32(&existingOp.waiterCount, 1)
|
atomic.AddInt32(&existingOp.waiterCount, 1)
|
||||||
return existingOp, false, nil
|
return existingOp, false, nil
|
||||||
}
|
}
|
||||||
@@ -301,71 +241,41 @@ func (rc *RefreshCoordinator) getOrCreateOperation(
|
|||||||
return nil, false, fmt.Errorf("refresh token mismatch")
|
return nil, false, fmt.Errorf("refresh token mismatch")
|
||||||
}
|
}
|
||||||
|
|
||||||
// We won the race and registered `candidate`. Apply gates now. If any
|
// No existing operation - check if we can create a new one
|
||||||
// gate fails we must remove our entry from the map and signal failure
|
// All checks happen while holding the lock to prevent races
|
||||||
// to any joiners that snuck in between LoadOrStore and now.
|
|
||||||
if err := rc.applyLeaderGates(sessionID); err != nil {
|
|
||||||
rc.failCandidate(tokenHash, candidate, err)
|
|
||||||
return nil, false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reserve concurrent slot via ticket-and-return: increment optimistically,
|
// Check and record refresh attempt for rate limiting
|
||||||
// decrement if we overshot the limit. The previous CAS-loop allowed a
|
rc.recordRefreshAttempt(sessionID)
|
||||||
// transient overshoot of up to N-1 leaders when several goroutines all
|
|
||||||
// observed `current < max` in the same scheduling slice before any one
|
|
||||||
// of them succeeded their CAS — visible to readers as
|
|
||||||
// currentInFlightRefreshes > MaxConcurrentRefreshes for a brief window.
|
|
||||||
// The ticket pattern is strictly bounded: the counter momentarily reads
|
|
||||||
// max+k for k concurrent attempts past the limit, but only the k that
|
|
||||||
// produced max+1..max+k decrement back, and only k=1 ever observes max+1
|
|
||||||
// as committed.
|
|
||||||
newCount := atomic.AddInt32(&rc.metrics.currentInFlightRefreshes, 1)
|
|
||||||
if int(newCount) > rc.config.MaxConcurrentRefreshes {
|
|
||||||
atomic.AddInt32(&rc.metrics.currentInFlightRefreshes, -1)
|
|
||||||
err := fmt.Errorf("maximum concurrent refresh operations reached")
|
|
||||||
rc.failCandidate(tokenHash, candidate, err)
|
|
||||||
return nil, false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return candidate, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyLeaderGates runs the rate-limit, cooldown, and memory-pressure checks
|
|
||||||
// that previously ran under the global refreshMutex. Only the leader (the
|
|
||||||
// goroutine that just registered the operation) runs them; joiners share the
|
|
||||||
// leader's outcome via operation.done.
|
|
||||||
func (rc *RefreshCoordinator) applyLeaderGates(sessionID string) error {
|
|
||||||
// Cooldown check FIRST, BEFORE incrementing the attempt counter.
|
|
||||||
// Previously this function recorded the attempt and then read the
|
|
||||||
// cooldown state. Under burst load (many concurrent leaders with
|
|
||||||
// different token hashes but same session) every goroutine could
|
|
||||||
// increment past MaxRefreshAttempts before any one of them observed
|
|
||||||
// the threshold, so the cooldown gate fired too late — the same
|
|
||||||
// thundering-herd shape that drove v1.0.14 into the ground.
|
|
||||||
if rc.isInCooldown(sessionID) {
|
if rc.isInCooldown(sessionID) {
|
||||||
atomic.AddInt64(&rc.metrics.cooldownsTriggered, 1)
|
atomic.AddInt64(&rc.metrics.cooldownsTriggered, 1)
|
||||||
return fmt.Errorf("refresh attempts exceeded for session, in cooldown period")
|
return nil, false, fmt.Errorf("refresh attempts exceeded for session, in cooldown period")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check memory pressure
|
||||||
if rc.config.EnableMemoryPressureDetection && rc.isUnderMemoryPressure() {
|
if rc.config.EnableMemoryPressureDetection && rc.isUnderMemoryPressure() {
|
||||||
atomic.AddInt64(&rc.metrics.memoryPressureEvents, 1)
|
atomic.AddInt64(&rc.metrics.memoryPressureEvents, 1)
|
||||||
return fmt.Errorf("system under memory pressure, refresh denied")
|
return nil, false, fmt.Errorf("system under memory pressure, refresh denied")
|
||||||
}
|
}
|
||||||
// Only count attempts that actually progress past the gates.
|
|
||||||
rc.recordRefreshAttempt(sessionID)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// failCandidate removes the leader's just-registered operation from the
|
// Check and reserve concurrent refresh slot atomically
|
||||||
// in-flight map and signals the error to any joiners by recording the result
|
current := atomic.LoadInt32(&rc.metrics.currentInFlightRefreshes)
|
||||||
// and closing the done channel. This keeps the (nil, false, err) return path
|
if int(current) >= rc.config.MaxConcurrentRefreshes {
|
||||||
// equivalent to the pre-sync.Map version: callers see the error directly,
|
return nil, false, fmt.Errorf("maximum concurrent refresh operations reached")
|
||||||
// joiners see it via operation.done.
|
}
|
||||||
func (rc *RefreshCoordinator) failCandidate(tokenHash string, op *refreshOperation, err error) {
|
|
||||||
rc.inFlightRefreshes.Delete(tokenHash)
|
// Reserve the slot - we're still holding the lock so this is safe
|
||||||
op.mutex.Lock()
|
atomic.AddInt32(&rc.metrics.currentInFlightRefreshes, 1)
|
||||||
op.result = &refreshResult{err: err}
|
|
||||||
op.mutex.Unlock()
|
// Create and register new operation
|
||||||
close(op.done)
|
operation := &refreshOperation{
|
||||||
|
refreshToken: refreshToken,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
startTime: time.Now(),
|
||||||
|
waiterCount: 1,
|
||||||
|
}
|
||||||
|
rc.inFlightRefreshes[tokenHash] = operation
|
||||||
|
|
||||||
|
return operation, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// executeRefreshAsync performs the actual refresh operation asynchronously
|
// executeRefreshAsync performs the actual refresh operation asynchronously
|
||||||
@@ -428,196 +338,130 @@ func (rc *RefreshCoordinator) executeRefreshAsync(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// scheduleDelayedCleanup schedules a cleanup using a timer instead of spawning
|
// scheduleDelayedCleanup schedules a cleanup using a timer instead of spawning a goroutine
|
||||||
// a goroutine — time.AfterFunc uses the runtime's timer heap and never spawns
|
// This prevents goroutine explosion under high load (500+ req/sec)
|
||||||
// a per-timer goroutine until the callback actually fires.
|
|
||||||
//
|
|
||||||
// The previous implementation tracked every pending timer in a map guarded by
|
|
||||||
// cleanupTimerMu so a duplicate scheduling could cancel the prior timer. That
|
|
||||||
// "shouldn't happen" path was the only consumer of the map, but the mutex
|
|
||||||
// fired on every successful refresh completion — yet another per-request
|
|
||||||
// Yaegi-dispatched lock acquisition. performCleanup is already idempotent
|
|
||||||
// (LoadAndDelete on the sync.Map), so a duplicate scheduling at worst fires
|
|
||||||
// performCleanup twice; the second call is a no-op. Dropping the map removes
|
|
||||||
// the whole class of contention on this code path.
|
|
||||||
func (rc *RefreshCoordinator) scheduleDelayedCleanup(tokenHash string) {
|
func (rc *RefreshCoordinator) scheduleDelayedCleanup(tokenHash string) {
|
||||||
delay := rc.config.DeduplicationCleanupDelay
|
delay := rc.config.DeduplicationCleanupDelay
|
||||||
if delay <= 0 {
|
if delay <= 0 {
|
||||||
|
// Immediate cleanup
|
||||||
rc.performCleanup(tokenHash)
|
rc.performCleanup(tokenHash)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
time.AfterFunc(delay, func() { rc.performCleanup(tokenHash) })
|
|
||||||
|
// Use time.AfterFunc which is more efficient than spawning a goroutine with Sleep
|
||||||
|
// time.AfterFunc uses the runtime's timer heap which is much more efficient
|
||||||
|
rc.cleanupTimerMu.Lock()
|
||||||
|
// Cancel any existing timer for this hash (shouldn't happen, but just in case)
|
||||||
|
if existingTimer, exists := rc.cleanupTimers[tokenHash]; exists {
|
||||||
|
existingTimer.Stop()
|
||||||
|
}
|
||||||
|
rc.cleanupTimers[tokenHash] = time.AfterFunc(delay, func() {
|
||||||
|
rc.performCleanup(tokenHash)
|
||||||
|
// Remove timer from map
|
||||||
|
rc.cleanupTimerMu.Lock()
|
||||||
|
delete(rc.cleanupTimers, tokenHash)
|
||||||
|
rc.cleanupTimerMu.Unlock()
|
||||||
|
})
|
||||||
|
rc.cleanupTimerMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// performCleanup removes the operation from the in-flight map.
|
// performCleanup removes the operation from the in-flight map.
|
||||||
// Idempotent: only decrements the in-flight counter if an entry was actually
|
// Idempotent: only decrements the in-flight counter if an entry was actually
|
||||||
// removed. LoadAndDelete is atomic so any concurrent failCandidate or repeat
|
// removed. This guards against any future path accidentally calling cleanup
|
||||||
// cleanup call will see exactly one removal — the budget cannot be corrupted
|
// twice for the same tokenHash (which would corrupt the refresh budget).
|
||||||
// by double-decrement.
|
|
||||||
func (rc *RefreshCoordinator) performCleanup(tokenHash string) {
|
func (rc *RefreshCoordinator) performCleanup(tokenHash string) {
|
||||||
if _, existed := rc.inFlightRefreshes.LoadAndDelete(tokenHash); existed {
|
rc.refreshMutex.Lock()
|
||||||
|
_, existed := rc.inFlightRefreshes[tokenHash]
|
||||||
|
if existed {
|
||||||
|
delete(rc.inFlightRefreshes, tokenHash)
|
||||||
|
}
|
||||||
|
rc.refreshMutex.Unlock()
|
||||||
|
if existed {
|
||||||
atomic.AddInt32(&rc.metrics.currentInFlightRefreshes, -1)
|
atomic.AddInt32(&rc.metrics.currentInFlightRefreshes, -1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// getOrCreateTracker fetches the tracker for sessionID or atomically creates a
|
// isInCooldown checks if a session is in cooldown after recording an attempt
|
||||||
// fresh one. The sync.Map.LoadOrStore semantics make this lock-free even under
|
|
||||||
// concurrent first-touch races: at most one tracker per sessionID survives.
|
|
||||||
//
|
|
||||||
// trackerFromMapValue centralizes the type assertion so the lint-mandated
|
|
||||||
// two-value form lives in one place; the stored type is always
|
|
||||||
// *refreshAttemptTracker by construction.
|
|
||||||
func trackerFromMapValue(v interface{}) *refreshAttemptTracker {
|
|
||||||
t, _ := v.(*refreshAttemptTracker)
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rc *RefreshCoordinator) getOrCreateTracker(sessionID string) *refreshAttemptTracker {
|
|
||||||
if v, ok := rc.sessionRefreshAttempts.Load(sessionID); ok {
|
|
||||||
return trackerFromMapValue(v)
|
|
||||||
}
|
|
||||||
fresh := &refreshAttemptTracker{}
|
|
||||||
fresh.state.Store(&attemptState{windowStartNano: time.Now().UnixNano()})
|
|
||||||
actual, _ := rc.sessionRefreshAttempts.LoadOrStore(sessionID, fresh)
|
|
||||||
return trackerFromMapValue(actual)
|
|
||||||
}
|
|
||||||
|
|
||||||
// mutateState performs a CompareAndSwap loop that applies mutate to the
|
|
||||||
// current snapshot. mutate must be PURE: it receives an immutable view of
|
|
||||||
// the current state and returns a fresh *attemptState. If mutate returns nil
|
|
||||||
// the update is skipped (used by isInCooldown for "no change needed" paths).
|
|
||||||
//
|
|
||||||
// Retries on CAS conflict are bounded by the number of concurrent writers —
|
|
||||||
// in practice 1-3. Under Yaegi each retry pays the dispatch cost of one Load
|
|
||||||
// + one CompareAndSwap; still cheaper than the previous per-field atomic
|
|
||||||
// sequence and immune to the cross-field race the v1.0.15 design had.
|
|
||||||
func (t *refreshAttemptTracker) mutateState(mutate func(cur *attemptState) *attemptState) *attemptState {
|
|
||||||
for {
|
|
||||||
cur := t.stateOf()
|
|
||||||
next := mutate(cur)
|
|
||||||
if next == nil {
|
|
||||||
return cur
|
|
||||||
}
|
|
||||||
if t.state.CompareAndSwap(t.state.Load(), next) {
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// isInCooldown checks if a session is in cooldown. Snapshot-based: every
|
|
||||||
// transition publishes a fresh *attemptState atomically so readers never see
|
|
||||||
// a partially-updated state. The previous per-field atomic design had a
|
|
||||||
// benign race in the cooldown-exit path (cooldownEndNano reset before
|
|
||||||
// attempts reset) that could double-trigger cooldown.
|
|
||||||
func (rc *RefreshCoordinator) isInCooldown(sessionID string) bool {
|
func (rc *RefreshCoordinator) isInCooldown(sessionID string) bool {
|
||||||
v, ok := rc.sessionRefreshAttempts.Load(sessionID)
|
rc.attemptsMutex.Lock()
|
||||||
if !ok {
|
defer rc.attemptsMutex.Unlock()
|
||||||
|
|
||||||
|
tracker, exists := rc.sessionRefreshAttempts[sessionID]
|
||||||
|
if !exists {
|
||||||
return false // No tracker means first attempt, not in cooldown
|
return false // No tracker means first attempt, not in cooldown
|
||||||
}
|
}
|
||||||
tracker := trackerFromMapValue(v)
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
nowNano := now.UnixNano()
|
|
||||||
maxAttempts := rc.config.MaxRefreshAttempts
|
|
||||||
window := rc.config.RefreshAttemptWindow
|
|
||||||
cooldownPeriod := rc.config.RefreshCooldownPeriod
|
|
||||||
|
|
||||||
cur := tracker.stateOf()
|
// Check if already in cooldown
|
||||||
|
if tracker.inCooldown {
|
||||||
// Already in cooldown?
|
if now.After(tracker.cooldownEndTime) {
|
||||||
if cur.cooldownEndNano != 0 {
|
// Cooldown expired, reset tracker
|
||||||
if nowNano <= cur.cooldownEndNano {
|
tracker.inCooldown = false
|
||||||
return true // still in cooldown
|
tracker.attempts = 1 // Already recorded one attempt
|
||||||
|
tracker.consecutiveFailures = 0
|
||||||
|
tracker.windowStartTime = now
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
// Cooldown expired: atomically publish a fresh state with the window
|
return true // Still in cooldown
|
||||||
// restarted from one attempt. Whichever goroutine wins the CAS sets
|
}
|
||||||
// the new snapshot; losers see it via the next stateOf load.
|
|
||||||
tracker.mutateState(func(s *attemptState) *attemptState {
|
// Check if window expired
|
||||||
if s.cooldownEndNano == 0 || nowNano <= s.cooldownEndNano {
|
if now.Sub(tracker.windowStartTime) > rc.config.RefreshAttemptWindow {
|
||||||
return nil // someone else already reset, or back in cooldown
|
// Reset window
|
||||||
}
|
tracker.attempts = 1 // Already recorded one attempt
|
||||||
return &attemptState{
|
tracker.windowStartTime = now
|
||||||
windowStartNano: nowNano,
|
|
||||||
attempts: 1,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Window expired?
|
// Check if just exceeded attempt limit
|
||||||
if time.Duration(nowNano-cur.windowStartNano) > window {
|
if int(tracker.attempts) >= rc.config.MaxRefreshAttempts {
|
||||||
tracker.mutateState(func(s *attemptState) *attemptState {
|
// Enter cooldown now
|
||||||
if time.Duration(nowNano-s.windowStartNano) <= window {
|
tracker.inCooldown = true
|
||||||
return nil
|
tracker.cooldownEndTime = now.Add(rc.config.RefreshCooldownPeriod)
|
||||||
}
|
rc.logger.Infof("Session %s entering refresh cooldown after %d attempts",
|
||||||
next := *s
|
sessionID, tracker.attempts)
|
||||||
next.windowStartNano = nowNano
|
|
||||||
next.attempts = 1
|
|
||||||
return &next
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Just exceeded attempt limit?
|
|
||||||
if int(cur.attempts) >= maxAttempts {
|
|
||||||
end := now.Add(cooldownPeriod).UnixNano()
|
|
||||||
published := tracker.mutateState(func(s *attemptState) *attemptState {
|
|
||||||
if s.cooldownEndNano != 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
next := *s
|
|
||||||
next.cooldownEndNano = end
|
|
||||||
return &next
|
|
||||||
})
|
|
||||||
if published.cooldownEndNano == end {
|
|
||||||
rc.logger.Infof("Session %s entering refresh cooldown after %d attempts",
|
|
||||||
sessionID, published.attempts)
|
|
||||||
}
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// recordRefreshAttempt records a refresh attempt for rate limiting. Lock-free
|
// recordRefreshAttempt records a refresh attempt for rate limiting
|
||||||
// snapshot mutation; attempts and lastAttemptNano are advanced atomically.
|
|
||||||
func (rc *RefreshCoordinator) recordRefreshAttempt(sessionID string) {
|
func (rc *RefreshCoordinator) recordRefreshAttempt(sessionID string) {
|
||||||
tracker := rc.getOrCreateTracker(sessionID)
|
rc.attemptsMutex.Lock()
|
||||||
nowNano := time.Now().UnixNano()
|
defer rc.attemptsMutex.Unlock()
|
||||||
tracker.mutateState(func(s *attemptState) *attemptState {
|
|
||||||
next := *s
|
|
||||||
next.attempts++
|
|
||||||
next.lastAttemptNano = nowNano
|
|
||||||
return &next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// recordRefreshSuccess records a successful refresh: zero consecutiveFailures.
|
tracker, exists := rc.sessionRefreshAttempts[sessionID]
|
||||||
func (rc *RefreshCoordinator) recordRefreshSuccess(sessionID string) {
|
if !exists {
|
||||||
v, ok := rc.sessionRefreshAttempts.Load(sessionID)
|
tracker = &refreshAttemptTracker{
|
||||||
if !ok {
|
windowStartTime: time.Now(),
|
||||||
return
|
|
||||||
}
|
|
||||||
trackerFromMapValue(v).mutateState(func(s *attemptState) *attemptState {
|
|
||||||
if s.consecutiveFailures == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
next := *s
|
rc.sessionRefreshAttempts[sessionID] = tracker
|
||||||
next.consecutiveFailures = 0
|
}
|
||||||
return &next
|
|
||||||
})
|
atomic.AddInt32(&tracker.attempts, 1)
|
||||||
|
tracker.lastAttemptTime = time.Now()
|
||||||
}
|
}
|
||||||
|
|
||||||
// recordRefreshFailure records a failed refresh: increments consecutiveFailures.
|
// recordRefreshSuccess records a successful refresh
|
||||||
|
func (rc *RefreshCoordinator) recordRefreshSuccess(sessionID string) {
|
||||||
|
rc.attemptsMutex.Lock()
|
||||||
|
defer rc.attemptsMutex.Unlock()
|
||||||
|
|
||||||
|
if tracker, exists := rc.sessionRefreshAttempts[sessionID]; exists {
|
||||||
|
tracker.consecutiveFailures = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordRefreshFailure records a failed refresh
|
||||||
func (rc *RefreshCoordinator) recordRefreshFailure(sessionID string) {
|
func (rc *RefreshCoordinator) recordRefreshFailure(sessionID string) {
|
||||||
v, ok := rc.sessionRefreshAttempts.Load(sessionID)
|
rc.attemptsMutex.Lock()
|
||||||
if !ok {
|
defer rc.attemptsMutex.Unlock()
|
||||||
return
|
|
||||||
|
if tracker, exists := rc.sessionRefreshAttempts[sessionID]; exists {
|
||||||
|
atomic.AddInt32(&tracker.consecutiveFailures, 1)
|
||||||
}
|
}
|
||||||
trackerFromMapValue(v).mutateState(func(s *attemptState) *attemptState {
|
|
||||||
next := *s
|
|
||||||
next.consecutiveFailures++
|
|
||||||
return &next
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// hashRefreshToken creates a hash of the refresh token for deduplication
|
// hashRefreshToken creates a hash of the refresh token for deduplication
|
||||||
@@ -668,22 +512,20 @@ func (rc *RefreshCoordinator) cleanupRoutine() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanupStaleEntries removes outdated tracking entries. Lock-free iteration
|
// cleanupStaleEntries removes outdated tracking entries
|
||||||
// via sync.Map.Range; safe to race with concurrent reads/writes.
|
|
||||||
func (rc *RefreshCoordinator) cleanupStaleEntries() {
|
func (rc *RefreshCoordinator) cleanupStaleEntries() {
|
||||||
cutoff := time.Now().Add(-2 * rc.config.RefreshAttemptWindow).UnixNano()
|
now := time.Now()
|
||||||
rc.sessionRefreshAttempts.Range(func(key, value interface{}) bool {
|
|
||||||
tracker := trackerFromMapValue(value)
|
rc.attemptsMutex.Lock()
|
||||||
if tracker == nil {
|
defer rc.attemptsMutex.Unlock()
|
||||||
return true
|
|
||||||
|
// Clean up old session trackers
|
||||||
|
for sessionID, tracker := range rc.sessionRefreshAttempts {
|
||||||
|
// Remove trackers that haven't been used recently
|
||||||
|
if now.Sub(tracker.lastAttemptTime) > 2*rc.config.RefreshAttemptWindow {
|
||||||
|
delete(rc.sessionRefreshAttempts, sessionID)
|
||||||
}
|
}
|
||||||
if tracker.stateOf().lastAttemptNano < cutoff {
|
}
|
||||||
// Compare-and-delete to avoid evicting a tracker that was just
|
|
||||||
// re-used by a concurrent caller. We compare by pointer identity.
|
|
||||||
rc.sessionRefreshAttempts.CompareAndDelete(key, value)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMetrics returns current coordinator metrics
|
// GetMetrics returns current coordinator metrics
|
||||||
@@ -701,60 +543,78 @@ func (rc *RefreshCoordinator) GetMetrics() map[string]interface{} {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shutdown gracefully shuts down the coordinator. Pending delayed-cleanup
|
// Shutdown gracefully shuts down the coordinator
|
||||||
// timers are NOT canceled explicitly: time.AfterFunc callbacks are tiny
|
|
||||||
// (one map LoadAndDelete) and harmless after Shutdown — sync.Map operations
|
|
||||||
// remain safe on an unused coordinator until GC.
|
|
||||||
func (rc *RefreshCoordinator) Shutdown() {
|
func (rc *RefreshCoordinator) Shutdown() {
|
||||||
close(rc.stopChan)
|
close(rc.stopChan)
|
||||||
|
|
||||||
|
// Cancel all pending cleanup timers
|
||||||
|
rc.cleanupTimerMu.Lock()
|
||||||
|
for _, timer := range rc.cleanupTimers {
|
||||||
|
timer.Stop()
|
||||||
|
}
|
||||||
|
rc.cleanupTimers = make(map[string]*time.Timer)
|
||||||
|
rc.cleanupTimerMu.Unlock()
|
||||||
|
|
||||||
rc.wg.Wait()
|
rc.wg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
// AllowRequest reports whether the circuit breaker allows a request. Lock-free.
|
// AllowRequest checks if the circuit breaker allows a request
|
||||||
func (cb *RefreshCircuitBreaker) AllowRequest() bool {
|
func (cb *RefreshCircuitBreaker) AllowRequest() bool {
|
||||||
switch atomic.LoadInt32(&cb.state) {
|
cb.mutex.RLock()
|
||||||
case 0: // closed
|
defer cb.mutex.RUnlock()
|
||||||
|
|
||||||
|
state := atomic.LoadInt32(&cb.state)
|
||||||
|
|
||||||
|
switch state {
|
||||||
|
case 0: // Closed
|
||||||
return true
|
return true
|
||||||
case 1: // open
|
case 1: // Open
|
||||||
lastFail := atomic.LoadInt64(&cb.lastFailureNano)
|
if time.Since(cb.lastFailureTime) > cb.config.OpenDuration {
|
||||||
if time.Duration(time.Now().UnixNano()-lastFail) > cb.config.OpenDuration {
|
// Try to transition to half-open
|
||||||
// Transition to half-open; first CAS winner gets the probe.
|
|
||||||
if atomic.CompareAndSwapInt32(&cb.state, 1, 2) {
|
if atomic.CompareAndSwapInt32(&cb.state, 1, 2) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
case 2: // half-open
|
case 2: // Half-open
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecordSuccess records a successful operation. Lock-free.
|
// RecordSuccess records a successful operation
|
||||||
func (cb *RefreshCircuitBreaker) RecordSuccess() {
|
func (cb *RefreshCircuitBreaker) RecordSuccess() {
|
||||||
switch atomic.LoadInt32(&cb.state) {
|
cb.mutex.Lock()
|
||||||
case 2: // half-open -> close
|
defer cb.mutex.Unlock()
|
||||||
|
|
||||||
|
state := atomic.LoadInt32(&cb.state)
|
||||||
|
if state == 2 { // Half-open
|
||||||
|
// Close the circuit
|
||||||
atomic.StoreInt32(&cb.state, 0)
|
atomic.StoreInt32(&cb.state, 0)
|
||||||
atomic.StoreInt32(&cb.failures, 0)
|
atomic.StoreInt32(&cb.failures, 0)
|
||||||
case 0: // closed
|
} else if state == 0 { // Closed
|
||||||
|
// Reset failure count on success
|
||||||
atomic.StoreInt32(&cb.failures, 0)
|
atomic.StoreInt32(&cb.failures, 0)
|
||||||
}
|
}
|
||||||
atomic.StoreInt64(&cb.lastSuccessNano, time.Now().UnixNano())
|
cb.lastSuccessTime = time.Now()
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecordFailure records a failed operation. Lock-free.
|
// RecordFailure records a failed operation
|
||||||
func (cb *RefreshCircuitBreaker) RecordFailure() {
|
func (cb *RefreshCircuitBreaker) RecordFailure() {
|
||||||
failures := atomic.AddInt32(&cb.failures, 1)
|
cb.mutex.Lock()
|
||||||
atomic.StoreInt64(&cb.lastFailureNano, time.Now().UnixNano())
|
defer cb.mutex.Unlock()
|
||||||
|
|
||||||
switch atomic.LoadInt32(&cb.state) {
|
failures := atomic.AddInt32(&cb.failures, 1)
|
||||||
case 0:
|
cb.lastFailureTime = time.Now()
|
||||||
if int(failures) >= cb.config.MaxFailures {
|
|
||||||
atomic.StoreInt32(&cb.state, 1)
|
state := atomic.LoadInt32(&cb.state)
|
||||||
}
|
|
||||||
case 2:
|
if state == 0 && int(failures) >= cb.config.MaxFailures {
|
||||||
// Half-open probe failed -> back to open.
|
// Open the circuit
|
||||||
|
atomic.StoreInt32(&cb.state, 1)
|
||||||
|
} else if state == 2 {
|
||||||
|
// Half-open failed, return to open
|
||||||
atomic.StoreInt32(&cb.state, 1)
|
atomic.StoreInt32(&cb.state, 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-28
@@ -165,14 +165,9 @@ func TestRefreshRateLimiting(t *testing.T) {
|
|||||||
time.Sleep(150 * time.Millisecond)
|
time.Sleep(150 * time.Millisecond)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify that cooldown was triggered after max attempts.
|
// Verify that cooldown was triggered after max attempts
|
||||||
// With applyLeaderGates checking cooldown BEFORE recording the attempt
|
// With the new logic, the Nth attempt triggers cooldown, so we get N-1 successful attempts
|
||||||
// (the v1.0.16 reorder fixing the thundering-herd off-by-one), N attempts
|
expectedSuccessfulAttempts := config.MaxRefreshAttempts - 1
|
||||||
// run to completion and the (N+1)th is denied. Previously the Nth was
|
|
||||||
// denied as it tried to record, which under burst load let multiple
|
|
||||||
// concurrent leaders increment past the limit before any one of them
|
|
||||||
// observed the gate.
|
|
||||||
expectedSuccessfulAttempts := config.MaxRefreshAttempts
|
|
||||||
if attempts != expectedSuccessfulAttempts {
|
if attempts != expectedSuccessfulAttempts {
|
||||||
t.Errorf("Expected %d successful attempts before cooldown, got %d", expectedSuccessfulAttempts, attempts)
|
t.Errorf("Expected %d successful attempts before cooldown, got %d", expectedSuccessfulAttempts, attempts)
|
||||||
}
|
}
|
||||||
@@ -370,12 +365,10 @@ func TestMemoryLeakPrevention(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify cleanup is working. sync.Map has no Len(); count via Range.
|
// Verify cleanup is working
|
||||||
sessionCount := 0
|
coordinator.attemptsMutex.RLock()
|
||||||
coordinator.sessionRefreshAttempts.Range(func(_, _ interface{}) bool {
|
sessionCount := len(coordinator.sessionRefreshAttempts)
|
||||||
sessionCount++
|
coordinator.attemptsMutex.RUnlock()
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
// Should have cleaned up old sessions (only recent ones remain)
|
// Should have cleaned up old sessions (only recent ones remain)
|
||||||
if sessionCount > numWorkers*2 {
|
if sessionCount > numWorkers*2 {
|
||||||
@@ -657,23 +650,24 @@ func TestCleanupRoutine(t *testing.T) {
|
|||||||
coordinator.recordRefreshAttempt(fmt.Sprintf("session_%d", i))
|
coordinator.recordRefreshAttempt(fmt.Sprintf("session_%d", i))
|
||||||
}
|
}
|
||||||
|
|
||||||
countSessions := func() int {
|
// Verify sessions exist
|
||||||
n := 0
|
coordinator.attemptsMutex.RLock()
|
||||||
coordinator.sessionRefreshAttempts.Range(func(_, _ interface{}) bool {
|
initialCount := len(coordinator.sessionRefreshAttempts)
|
||||||
n++
|
coordinator.attemptsMutex.RUnlock()
|
||||||
return true
|
|
||||||
})
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
if initialCount := countSessions(); initialCount != 5 {
|
if initialCount != 5 {
|
||||||
t.Errorf("Expected 5 sessions, got %d", initialCount)
|
t.Errorf("Expected 5 sessions, got %d", initialCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for cleanup to run (2x window + cleanup interval)
|
// Wait for cleanup to run (2x window + cleanup interval)
|
||||||
time.Sleep(2*config.RefreshAttemptWindow + 2*config.CleanupInterval)
|
time.Sleep(2*config.RefreshAttemptWindow + 2*config.CleanupInterval)
|
||||||
|
|
||||||
if finalCount := countSessions(); finalCount != 0 {
|
// Verify sessions were cleaned up
|
||||||
|
coordinator.attemptsMutex.RLock()
|
||||||
|
finalCount := len(coordinator.sessionRefreshAttempts)
|
||||||
|
coordinator.attemptsMutex.RUnlock()
|
||||||
|
|
||||||
|
if finalCount != 0 {
|
||||||
t.Errorf("Expected 0 sessions after cleanup, got %d", finalCount)
|
t.Errorf("Expected 0 sessions after cleanup, got %d", finalCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -726,9 +720,11 @@ func TestNoGoroutineExplosionWithTimers(t *testing.T) {
|
|||||||
currentGoroutines := runtime.NumGoroutine()
|
currentGoroutines := runtime.NumGoroutine()
|
||||||
t.Logf("Goroutines after %d refresh operations: %d", numRefreshes, currentGoroutines)
|
t.Logf("Goroutines after %d refresh operations: %d", numRefreshes, currentGoroutines)
|
||||||
|
|
||||||
// (Coordinator no longer tracks pending timers; time.AfterFunc closures
|
// Check timer count
|
||||||
// fire performCleanup directly. This test now only checks the goroutine
|
coordinator.cleanupTimerMu.Lock()
|
||||||
// budget, which was always the real invariant.)
|
timerCount := len(coordinator.cleanupTimers)
|
||||||
|
coordinator.cleanupTimerMu.Unlock()
|
||||||
|
t.Logf("Active cleanup timers: %d", timerCount)
|
||||||
|
|
||||||
// With timer-based cleanup, goroutine increase should be minimal
|
// With timer-based cleanup, goroutine increase should be minimal
|
||||||
// Timers don't create goroutines - they use the runtime timer heap
|
// Timers don't create goroutines - they use the runtime timer heap
|
||||||
@@ -744,9 +740,19 @@ func TestNoGoroutineExplosionWithTimers(t *testing.T) {
|
|||||||
initialGoroutines, currentGoroutines, goroutineIncrease)
|
initialGoroutines, currentGoroutines, goroutineIncrease)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for timers to fire and cleanup.
|
// Wait for timers to fire and cleanup
|
||||||
time.Sleep(config.DeduplicationCleanupDelay + 50*time.Millisecond)
|
time.Sleep(config.DeduplicationCleanupDelay + 50*time.Millisecond)
|
||||||
|
|
||||||
|
// Verify timers were cleaned up
|
||||||
|
coordinator.cleanupTimerMu.Lock()
|
||||||
|
remainingTimers := len(coordinator.cleanupTimers)
|
||||||
|
coordinator.cleanupTimerMu.Unlock()
|
||||||
|
|
||||||
|
// Most timers should have fired and been removed
|
||||||
|
if remainingTimers > 10 {
|
||||||
|
t.Errorf("Too many cleanup timers remaining: %d", remainingTimers)
|
||||||
|
}
|
||||||
|
|
||||||
// Verify goroutines returned to near initial
|
// Verify goroutines returned to near initial
|
||||||
runtime.GC()
|
runtime.GC()
|
||||||
time.Sleep(50 * time.Millisecond)
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
// Package traefikoidc provides OIDC authentication middleware for Traefik.
|
|
||||||
// requestState bundles read-mostly fields for a single ServeHTTP call.
|
|
||||||
package traefikoidc
|
|
||||||
|
|
||||||
import "net/http"
|
|
||||||
|
|
||||||
// requestState is a per-request context object allocated at the top of
|
|
||||||
// ServeHTTP and threaded through to downstream handlers. It caches values
|
|
||||||
// that would otherwise require a Yaegi-dispatched lock acquisition each time
|
|
||||||
// they're read:
|
|
||||||
//
|
|
||||||
// - The metadata snapshot (atomic.Value.Load once, not per-handler).
|
|
||||||
// - SessionData getter results (one RLock on sd.sessionMutex covers all
|
|
||||||
// fields, instead of 5-7 separate RLock/RUnlock pairs scattered through
|
|
||||||
// the handler chain).
|
|
||||||
//
|
|
||||||
// The struct is alloc'd at request entry, populated under at most one RLock
|
|
||||||
// of sd.sessionMutex, and discarded at request exit. It is NOT shared across
|
|
||||||
// requests and never written from another goroutine, so no synchronization
|
|
||||||
// on its fields is required.
|
|
||||||
//
|
|
||||||
// Cross-request global caches (tokenCache, JWKCache, sessionEntries,
|
|
||||||
// sessionInvalidationCache) remain — they're orthogonal. requestState's job
|
|
||||||
// is to eliminate redundant per-handler reads of values that don't change
|
|
||||||
// within a single request.
|
|
||||||
type requestState struct {
|
|
||||||
// Globals snapshotted once.
|
|
||||||
metadata *MetadataSnapshot
|
|
||||||
|
|
||||||
// SessionData fields snapshotted under one RLock. The pointer to the
|
|
||||||
// SessionData is retained so handlers that genuinely need to mutate
|
|
||||||
// (Save, Clear, etc.) still have access.
|
|
||||||
session *SessionData
|
|
||||||
|
|
||||||
authenticated bool
|
|
||||||
accessToken string
|
|
||||||
idToken string
|
|
||||||
refreshToken string
|
|
||||||
userIdentifier string
|
|
||||||
createdAtUnixSec int64
|
|
||||||
|
|
||||||
// Output: scheme/host/redirect path determined at top of ServeHTTP.
|
|
||||||
scheme string
|
|
||||||
host string
|
|
||||||
redirectURL string
|
|
||||||
|
|
||||||
// Carry the next handler so forwardAuthorized doesn't need to close over t.
|
|
||||||
next http.Handler
|
|
||||||
}
|
|
||||||
|
|
||||||
// captureSession populates requestState's SessionData-derived fields under a
|
|
||||||
// single RLock of sd.sessionMutex. Returns the populated rs for chaining.
|
|
||||||
//
|
|
||||||
// Replaces a sequence of SessionData.GetX() calls each of which acquires
|
|
||||||
// sd.sessionMutex.RLock(). Under Yaegi each RLock costs ~1-5ms of
|
|
||||||
// interpreter dispatch; batching saves the rest.
|
|
||||||
func (rs *requestState) captureSession(sd *SessionData) *requestState {
|
|
||||||
if sd == nil {
|
|
||||||
return rs
|
|
||||||
}
|
|
||||||
rs.session = sd
|
|
||||||
sd.sessionMutex.RLock()
|
|
||||||
rs.authenticated = sd.getAuthenticatedUnsafe()
|
|
||||||
rs.accessToken = sd.getAccessTokenUnsafe()
|
|
||||||
rs.idToken = sd.getIDTokenUnsafe()
|
|
||||||
rs.refreshToken = sd.getRefreshTokenUnsafe()
|
|
||||||
rs.userIdentifier = sd.getUserIdentifierUnsafe()
|
|
||||||
rs.createdAtUnixSec = sd.getCreatedAtUnsafe()
|
|
||||||
sd.sessionMutex.RUnlock()
|
|
||||||
return rs
|
|
||||||
}
|
|
||||||
@@ -126,6 +126,13 @@ type Config struct {
|
|||||||
// Supported: RS256/384/512, PS256/384/512, ES256/384/512.
|
// Supported: RS256/384/512, PS256/384/512, ES256/384/512.
|
||||||
ClientAssertionAlg string `json:"clientAssertionAlg,omitempty"`
|
ClientAssertionAlg string `json:"clientAssertionAlg,omitempty"`
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
|
||||||
// --- Bearer-token auth (opt-in M2M path) ---
|
// --- Bearer-token auth (opt-in M2M path) ---
|
||||||
|
|
||||||
// EnableBearerAuth turns on the Authorization: Bearer <jwt> auth path.
|
// EnableBearerAuth turns on the Authorization: Bearer <jwt> auth path.
|
||||||
|
|||||||
-142
@@ -1,142 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// pluginVersion is bumped manually on each release. Keep in sync with the
|
|
||||||
// most recent git tag (see `git tag --sort=-v:refname | head -1`).
|
|
||||||
const pluginVersion = "1.0.11"
|
|
||||||
|
|
||||||
const (
|
|
||||||
telemetryProject = "traefikoidc"
|
|
||||||
telemetryTimeout = 2 * time.Second
|
|
||||||
)
|
|
||||||
|
|
||||||
// telemetryEndpoint is intentionally a var rather than a const so the test
|
|
||||||
// suite in this package can retarget it at an httptest server. Production
|
|
||||||
// code never mutates it.
|
|
||||||
var telemetryEndpoint = "https://oss.raczylo.com/v1/ping"
|
|
||||||
|
|
||||||
// telemetryOnce guarantees a single anonymous "plugin loaded" ping per
|
|
||||||
// process lifetime. Traefik can instantiate a middleware many times per
|
|
||||||
// process (one per route using the plugin); the sync.Once gate keeps the
|
|
||||||
// fire-and-forget call from amplifying into many pings.
|
|
||||||
//
|
|
||||||
// Reset in tests via `telemetryOnce = sync.Once{}`.
|
|
||||||
var telemetryOnce sync.Once
|
|
||||||
|
|
||||||
// telemetryInflight tracks any background goroutine started by sendTelemetry.
|
|
||||||
// Tests Wait on it to drain in-flight goroutines before mutating package
|
|
||||||
// state. Production code never calls Wait — the goroutine is fire-and-forget.
|
|
||||||
var telemetryInflight sync.WaitGroup
|
|
||||||
|
|
||||||
// sendTelemetry fires one anonymous usage ping in the background. It is
|
|
||||||
// failproof by contract:
|
|
||||||
//
|
|
||||||
// - never blocks the caller
|
|
||||||
// - never panics (the goroutine recovers internally)
|
|
||||||
// - never returns errors
|
|
||||||
// - silently dropped on invalid input, env-driven opt-out, or network failure
|
|
||||||
//
|
|
||||||
// Opt-out is honored via any of:
|
|
||||||
//
|
|
||||||
// - DO_NOT_TRACK=1
|
|
||||||
// - OSS_TELEMETRY_DISABLED=1
|
|
||||||
// - TRAEFIKOIDC_DISABLE_TELEMETRY=1
|
|
||||||
//
|
|
||||||
// Yaegi note: this file deliberately avoids generics (atomic.Pointer[T]) and
|
|
||||||
// range-over-int (Go 1.22) so it interprets under any reasonably recent
|
|
||||||
// Traefik yaegi runtime.
|
|
||||||
func sendTelemetry(version string) {
|
|
||||||
telemetryOnce.Do(func() {
|
|
||||||
if telemetryDisabledByEnv() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !validTelemetryVersion(version) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
telemetryInflight.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer telemetryInflight.Done()
|
|
||||||
defer func() { _ = recover() }()
|
|
||||||
doTelemetryPost(version)
|
|
||||||
}()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func telemetryDisabledByEnv() bool {
|
|
||||||
keys := []string{
|
|
||||||
"DO_NOT_TRACK",
|
|
||||||
"OSS_TELEMETRY_DISABLED",
|
|
||||||
"TRAEFIKOIDC_DISABLE_TELEMETRY",
|
|
||||||
}
|
|
||||||
for _, k := range keys {
|
|
||||||
v := strings.ToLower(strings.TrimSpace(os.Getenv(k)))
|
|
||||||
if v == "1" || v == "true" || v == "yes" || v == "on" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// validTelemetryVersion mirrors the server-side regex ^[A-Za-z0-9.+_-]{1,32}$
|
|
||||||
// using a byte loop. No allocation, no regexp dependency.
|
|
||||||
//
|
|
||||||
// Yaegi note: written as an `||` chain rather than `switch{case A,B,C:}` —
|
|
||||||
// some yaegi releases mis-evaluate comma-separated case expressions in
|
|
||||||
// switch-true blocks, returning false for all inputs.
|
|
||||||
func validTelemetryVersion(v string) bool {
|
|
||||||
if len(v) == 0 || len(v) > 32 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for i := 0; i < len(v); i++ {
|
|
||||||
c := v[i]
|
|
||||||
ok := (c >= 'A' && c <= 'Z') ||
|
|
||||||
(c >= 'a' && c <= 'z') ||
|
|
||||||
(c >= '0' && c <= '9') ||
|
|
||||||
c == '.' || c == '+' || c == '_' || c == '-'
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// doTelemetryPost builds the JSON body manually. The project name is a
|
|
||||||
// constant and the version is pre-validated against an ASCII-only allowlist,
|
|
||||||
// so direct concatenation needs no JSON escaping.
|
|
||||||
func doTelemetryPost(version string) {
|
|
||||||
body := make([]byte, 0, 96)
|
|
||||||
body = append(body, `{"project":"`...)
|
|
||||||
body = append(body, telemetryProject...)
|
|
||||||
body = append(body, `","version":"`...)
|
|
||||||
body = append(body, version...)
|
|
||||||
body = append(body, `","ts":`...)
|
|
||||||
body = strconv.AppendInt(body, time.Now().Unix(), 10)
|
|
||||||
body = append(body, '}')
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), telemetryTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
url := telemetryEndpoint
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: telemetryTimeout}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// resetTelemetryState restores package-level mutable state so tests do not
|
|
||||||
// contaminate one another. The cleanup waits for any in-flight ping goroutine
|
|
||||||
// to finish before restoring telemetryEndpoint — without that drain step the
|
|
||||||
// goroutine and the cleanup would race on the var.
|
|
||||||
func resetTelemetryState(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
telemetryOnce = sync.Once{}
|
|
||||||
prev := telemetryEndpoint
|
|
||||||
t.Cleanup(func() {
|
|
||||||
telemetryInflight.Wait()
|
|
||||||
telemetryEndpoint = prev
|
|
||||||
telemetryOnce = sync.Once{}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTelemetryServer(t *testing.T, status int) (hits *int32, lastBody func() string) {
|
|
||||||
t.Helper()
|
|
||||||
var counter int32
|
|
||||||
var mu sync.Mutex
|
|
||||||
var body string
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
atomic.AddInt32(&counter, 1)
|
|
||||||
b, _ := io.ReadAll(r.Body)
|
|
||||||
_ = r.Body.Close()
|
|
||||||
mu.Lock()
|
|
||||||
body = string(b)
|
|
||||||
mu.Unlock()
|
|
||||||
w.WriteHeader(status)
|
|
||||||
}))
|
|
||||||
telemetryEndpoint = srv.URL
|
|
||||||
t.Cleanup(srv.Close)
|
|
||||||
return &counter, func() string {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidTelemetryVersion(t *testing.T) {
|
|
||||||
good := []string{"1.2.3", "1.4.0-beta1", "2.0", "v1.0.0", "1.0.0+meta", "dev"}
|
|
||||||
for _, v := range good {
|
|
||||||
if !validTelemetryVersion(v) {
|
|
||||||
t.Errorf("validTelemetryVersion(%q) = false, want true", v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bad := []string{"", "has space", "semi;colon", strings.Repeat("1", 33)}
|
|
||||||
for _, v := range bad {
|
|
||||||
if validTelemetryVersion(v) {
|
|
||||||
t.Errorf("validTelemetryVersion(%q) = true, want false", v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTelemetryDisabledByEnv(t *testing.T) {
|
|
||||||
for _, k := range []string{"DO_NOT_TRACK", "OSS_TELEMETRY_DISABLED", "TRAEFIKOIDC_DISABLE_TELEMETRY"} {
|
|
||||||
t.Run(k, func(t *testing.T) {
|
|
||||||
t.Setenv(k, "1")
|
|
||||||
if !telemetryDisabledByEnv() {
|
|
||||||
t.Fatalf("%s=1 should disable", k)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
t.Run("falsy_values_do_not_disable", func(t *testing.T) {
|
|
||||||
t.Setenv("DO_NOT_TRACK", "0")
|
|
||||||
t.Setenv("OSS_TELEMETRY_DISABLED", "false")
|
|
||||||
t.Setenv("TRAEFIKOIDC_DISABLE_TELEMETRY", "no")
|
|
||||||
if telemetryDisabledByEnv() {
|
|
||||||
t.Fatal("falsy env values should not disable")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendTelemetry_FiresOnceAcrossManyCalls(t *testing.T) {
|
|
||||||
resetTelemetryState(t)
|
|
||||||
hits, lastBody := newTelemetryServer(t, http.StatusNoContent)
|
|
||||||
|
|
||||||
for i := 0; i < 50; i++ {
|
|
||||||
sendTelemetry("1.2.3")
|
|
||||||
}
|
|
||||||
telemetryInflight.Wait()
|
|
||||||
|
|
||||||
if got := atomic.LoadInt32(hits); got != 1 {
|
|
||||||
t.Fatalf("expected exactly 1 hit, got %d", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
var payload struct {
|
|
||||||
Project string `json:"project"`
|
|
||||||
Version string `json:"version"`
|
|
||||||
Ts int64 `json:"ts"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(lastBody()), &payload); err != nil {
|
|
||||||
t.Fatalf("server received non-JSON body: %q (err: %v)", lastBody(), err)
|
|
||||||
}
|
|
||||||
if payload.Project != "traefikoidc" || payload.Version != "1.2.3" || payload.Ts <= 0 {
|
|
||||||
t.Fatalf("unexpected payload: %+v", payload)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendTelemetry_RespectsDisableEnv(t *testing.T) {
|
|
||||||
resetTelemetryState(t)
|
|
||||||
hits, _ := newTelemetryServer(t, http.StatusNoContent)
|
|
||||||
t.Setenv("DO_NOT_TRACK", "1")
|
|
||||||
|
|
||||||
sendTelemetry("1.2.3")
|
|
||||||
telemetryInflight.Wait()
|
|
||||||
|
|
||||||
if got := atomic.LoadInt32(hits); got != 0 {
|
|
||||||
t.Fatalf("DO_NOT_TRACK should suppress; got %d hits", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendTelemetry_DropsInvalidVersion(t *testing.T) {
|
|
||||||
resetTelemetryState(t)
|
|
||||||
hits, _ := newTelemetryServer(t, http.StatusNoContent)
|
|
||||||
|
|
||||||
sendTelemetry("has space")
|
|
||||||
telemetryInflight.Wait()
|
|
||||||
|
|
||||||
if got := atomic.LoadInt32(hits); got != 0 {
|
|
||||||
t.Fatalf("invalid version should suppress; got %d hits", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendTelemetry_DoesNotBlock(t *testing.T) {
|
|
||||||
resetTelemetryState(t)
|
|
||||||
// Hanging server proves the caller is never blocked. The 2s context
|
|
||||||
// timeout in doTelemetryPost ensures the goroutine eventually exits;
|
|
||||||
// resetTelemetryState's cleanup waits for that drain before restoring
|
|
||||||
// telemetryEndpoint so there is no race with this test's mutation.
|
|
||||||
hung := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
|
||||||
time.Sleep(5 * time.Second)
|
|
||||||
}))
|
|
||||||
t.Cleanup(hung.Close)
|
|
||||||
telemetryEndpoint = hung.URL
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
sendTelemetry("1.2.3")
|
|
||||||
if elapsed := time.Since(start); elapsed > 50*time.Millisecond {
|
|
||||||
t.Fatalf("sendTelemetry blocked for %v, expected near-instant return", elapsed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendTelemetry_SurvivesServerError(t *testing.T) {
|
|
||||||
resetTelemetryState(t)
|
|
||||||
hits, _ := newTelemetryServer(t, http.StatusInternalServerError)
|
|
||||||
|
|
||||||
sendTelemetry("1.2.3")
|
|
||||||
telemetryInflight.Wait()
|
|
||||||
|
|
||||||
if got := atomic.LoadInt32(hits); got != 1 {
|
|
||||||
t.Fatalf("request should still reach server even on 500; got %d hits", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,279 +0,0 @@
|
|||||||
// Package traefikoidc provides OIDC authentication middleware for Traefik.
|
|
||||||
// This file contains requestState-aware variants of the token validation
|
|
||||||
// functions. They read session field values from the captured snapshot in
|
|
||||||
// *requestState instead of calling session.GetX(), eliminating ~21 RLock
|
|
||||||
// acquisitions on sd.sessionMutex per request through the validation path
|
|
||||||
// (validateStandardTokens reads 17, validateAzureTokens reads 10,
|
|
||||||
// validateTokenExpiry reads 4 — and many are the SAME field). Under Yaegi
|
|
||||||
// each RLock costs ~1-5ms of interpreter dispatch.
|
|
||||||
//
|
|
||||||
// The non-RS variants are retained for paths that don't have a captured
|
|
||||||
// snapshot (tests that drive the validators directly, the Azure/Google path
|
|
||||||
// when reached without rs threading, etc).
|
|
||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// isUserAuthenticatedRS is the requestState-aware variant of
|
|
||||||
// isUserAuthenticated. Dispatches to the right per-provider validator based
|
|
||||||
// on the configured provider, all of which read from rs instead of session.
|
|
||||||
func (t *TraefikOidc) isUserAuthenticatedRS(rs *requestState) (bool, bool, bool) {
|
|
||||||
if t.isAzureProvider() {
|
|
||||||
return t.validateAzureTokensRS(rs)
|
|
||||||
} else if t.isGoogleProvider() {
|
|
||||||
return t.validateStandardTokensRS(rs)
|
|
||||||
}
|
|
||||||
return t.validateStandardTokensRS(rs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateTokenExpiryRS is the requestState-aware variant of validateTokenExpiry.
|
|
||||||
// Reads rs.refreshToken instead of session.GetRefreshToken() (4 RLocks avoided).
|
|
||||||
func (t *TraefikOidc) validateTokenExpiryRS(rs *requestState, token string) (bool, bool, bool) {
|
|
||||||
cachedClaims, found := t.tokenCache.Get(token)
|
|
||||||
if !found {
|
|
||||||
t.logger.Debug("Claims not found in cache after successful token verification")
|
|
||||||
if rs.refreshToken != "" {
|
|
||||||
return false, true, false
|
|
||||||
}
|
|
||||||
return false, false, true
|
|
||||||
}
|
|
||||||
|
|
||||||
expClaim, ok := cachedClaims["exp"].(float64)
|
|
||||||
if !ok {
|
|
||||||
t.logger.Error("Failed to get expiration time ('exp' claim) from verified token")
|
|
||||||
if rs.refreshToken != "" {
|
|
||||||
return false, true, false
|
|
||||||
}
|
|
||||||
return false, false, true
|
|
||||||
}
|
|
||||||
|
|
||||||
expTimeObj := time.Unix(int64(expClaim), 0)
|
|
||||||
nowObj := time.Now()
|
|
||||||
|
|
||||||
if expTimeObj.Before(nowObj) {
|
|
||||||
if rs.refreshToken != "" {
|
|
||||||
return false, true, false
|
|
||||||
}
|
|
||||||
return false, false, true
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshThreshold := nowObj.Add(t.refreshGracePeriod)
|
|
||||||
if expTimeObj.Before(refreshThreshold) {
|
|
||||||
if rs.refreshToken != "" {
|
|
||||||
return true, true, false
|
|
||||||
}
|
|
||||||
return true, false, false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true, false, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateStandardTokensRS is the requestState-aware variant of
|
|
||||||
// validateStandardTokens. Replaces all session.GetX() calls (17 of them in
|
|
||||||
// the non-RS variant, dominated by GetRefreshToken called 11 times) with
|
|
||||||
// rs field reads. Same control flow.
|
|
||||||
//
|
|
||||||
//nolint:gocognit,gocyclo // Mirrors validateStandardTokens complexity by design.
|
|
||||||
func (t *TraefikOidc) validateStandardTokensRS(rs *requestState) (bool, bool, bool) {
|
|
||||||
if !rs.authenticated {
|
|
||||||
if rs.refreshToken != "" {
|
|
||||||
return false, true, false
|
|
||||||
}
|
|
||||||
return false, false, false
|
|
||||||
}
|
|
||||||
|
|
||||||
if rs.accessToken == "" {
|
|
||||||
if rs.refreshToken != "" {
|
|
||||||
// ID-token grace-period check (only when accessToken is absent).
|
|
||||||
if rs.idToken != "" {
|
|
||||||
parts := strings.Split(rs.idToken, ".")
|
|
||||||
if len(parts) == 3 {
|
|
||||||
if claimsData, err := base64.RawURLEncoding.DecodeString(parts[1]); err == nil {
|
|
||||||
var claims map[string]interface{}
|
|
||||||
if err := json.Unmarshal(claimsData, &claims); err == nil {
|
|
||||||
if expClaim, ok := claims["exp"].(float64); ok {
|
|
||||||
expTime := time.Unix(int64(expClaim), 0)
|
|
||||||
if time.Now().After(expTime) {
|
|
||||||
expiredDuration := time.Since(expTime)
|
|
||||||
if expiredDuration > t.refreshGracePeriod {
|
|
||||||
return false, false, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false, true, false
|
|
||||||
}
|
|
||||||
return false, false, true
|
|
||||||
}
|
|
||||||
|
|
||||||
dotCount := strings.Count(rs.accessToken, ".")
|
|
||||||
isOpaqueToken := dotCount != 2
|
|
||||||
|
|
||||||
if isOpaqueToken {
|
|
||||||
if t.allowOpaqueTokens {
|
|
||||||
if err := t.validateOpaqueToken(rs.accessToken); err != nil {
|
|
||||||
errMsg := err.Error()
|
|
||||||
isTokenInvalid := strings.Contains(errMsg, "token is not active") ||
|
|
||||||
strings.Contains(errMsg, "revoked") ||
|
|
||||||
strings.Contains(errMsg, "token has expired")
|
|
||||||
if isTokenInvalid {
|
|
||||||
if rs.refreshToken != "" {
|
|
||||||
return false, true, false
|
|
||||||
}
|
|
||||||
return false, false, true
|
|
||||||
}
|
|
||||||
if t.requireTokenIntrospection {
|
|
||||||
if rs.refreshToken != "" {
|
|
||||||
return false, true, false
|
|
||||||
}
|
|
||||||
return false, false, true
|
|
||||||
}
|
|
||||||
// Transient introspection error: fall through to ID-token validation.
|
|
||||||
} else {
|
|
||||||
// Introspection succeeded.
|
|
||||||
if rs.idToken != "" {
|
|
||||||
return t.validateTokenExpiryRS(rs, rs.idToken)
|
|
||||||
}
|
|
||||||
return true, false, false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to ID-token validation when opaque + no successful introspection.
|
|
||||||
if rs.idToken == "" {
|
|
||||||
if rs.refreshToken != "" {
|
|
||||||
return false, true, false
|
|
||||||
}
|
|
||||||
return true, false, false
|
|
||||||
}
|
|
||||||
if err := t.verifyToken(rs.idToken); err != nil {
|
|
||||||
if strings.Contains(err.Error(), "token has expired") {
|
|
||||||
if rs.refreshToken != "" {
|
|
||||||
return false, true, false
|
|
||||||
}
|
|
||||||
return false, false, true
|
|
||||||
}
|
|
||||||
if rs.refreshToken != "" {
|
|
||||||
return false, true, false
|
|
||||||
}
|
|
||||||
return false, false, true
|
|
||||||
}
|
|
||||||
return t.validateTokenExpiryRS(rs, rs.idToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
// JWT access token present.
|
|
||||||
accessTokenValid := false
|
|
||||||
if err := t.verifyToken(rs.accessToken); err != nil {
|
|
||||||
errMsg := err.Error()
|
|
||||||
if strings.Contains(errMsg, "invalid audience") || strings.Contains(errMsg, "audience") {
|
|
||||||
if t.strictAudienceValidation {
|
|
||||||
if rs.refreshToken != "" {
|
|
||||||
return false, true, false
|
|
||||||
}
|
|
||||||
return false, false, true
|
|
||||||
}
|
|
||||||
// Fall through to ID-token validation.
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
accessTokenValid = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if rs.idToken == "" {
|
|
||||||
if accessTokenValid {
|
|
||||||
return t.validateTokenExpiryRS(rs, rs.accessToken)
|
|
||||||
}
|
|
||||||
if rs.refreshToken != "" {
|
|
||||||
return true, true, false
|
|
||||||
}
|
|
||||||
return true, false, false
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := t.verifyToken(rs.idToken); err != nil {
|
|
||||||
if strings.Contains(err.Error(), "token has expired") {
|
|
||||||
if rs.refreshToken != "" {
|
|
||||||
return false, true, false
|
|
||||||
}
|
|
||||||
return false, false, true
|
|
||||||
}
|
|
||||||
if rs.refreshToken != "" {
|
|
||||||
return false, true, false
|
|
||||||
}
|
|
||||||
return false, false, true
|
|
||||||
}
|
|
||||||
|
|
||||||
if accessTokenValid {
|
|
||||||
return t.validateTokenExpiryRS(rs, rs.accessToken)
|
|
||||||
}
|
|
||||||
return t.validateTokenExpiryRS(rs, rs.idToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateAzureTokensRS is the requestState-aware variant of validateAzureTokens.
|
|
||||||
// Eliminates 10 session.GetX() RLocks per Azure-path request.
|
|
||||||
func (t *TraefikOidc) validateAzureTokensRS(rs *requestState) (bool, bool, bool) {
|
|
||||||
if !rs.authenticated {
|
|
||||||
if rs.refreshToken != "" {
|
|
||||||
return false, true, false
|
|
||||||
}
|
|
||||||
return false, true, false
|
|
||||||
}
|
|
||||||
|
|
||||||
if rs.accessToken != "" {
|
|
||||||
if strings.Count(rs.accessToken, ".") == 2 {
|
|
||||||
if t.isUnverifiableAzureAccessToken(rs.accessToken) {
|
|
||||||
if rs.idToken != "" {
|
|
||||||
if err := t.verifyToken(rs.idToken); err != nil {
|
|
||||||
if rs.refreshToken != "" {
|
|
||||||
return false, true, false
|
|
||||||
}
|
|
||||||
return false, false, true
|
|
||||||
}
|
|
||||||
return t.validateTokenExpiryRS(rs, rs.idToken)
|
|
||||||
}
|
|
||||||
return true, false, false
|
|
||||||
}
|
|
||||||
if err := t.verifyToken(rs.accessToken); err != nil {
|
|
||||||
if rs.idToken != "" {
|
|
||||||
if err := t.verifyToken(rs.idToken); err != nil {
|
|
||||||
if rs.refreshToken != "" {
|
|
||||||
return false, true, false
|
|
||||||
}
|
|
||||||
return false, false, true
|
|
||||||
}
|
|
||||||
return t.validateTokenExpiryRS(rs, rs.idToken)
|
|
||||||
}
|
|
||||||
if rs.refreshToken != "" {
|
|
||||||
return false, true, false
|
|
||||||
}
|
|
||||||
return false, false, true
|
|
||||||
}
|
|
||||||
return t.validateTokenExpiryRS(rs, rs.accessToken)
|
|
||||||
}
|
|
||||||
// Opaque access token.
|
|
||||||
if rs.idToken != "" {
|
|
||||||
return t.validateTokenExpiryRS(rs, rs.idToken)
|
|
||||||
}
|
|
||||||
return true, false, false
|
|
||||||
}
|
|
||||||
|
|
||||||
if rs.idToken != "" {
|
|
||||||
if err := t.verifyToken(rs.idToken); err != nil {
|
|
||||||
if rs.refreshToken != "" {
|
|
||||||
return false, true, false
|
|
||||||
}
|
|
||||||
return false, false, true
|
|
||||||
}
|
|
||||||
return t.validateTokenExpiryRS(rs, rs.idToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
if rs.refreshToken != "" {
|
|
||||||
return false, true, false
|
|
||||||
}
|
|
||||||
return false, false, true
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
|
||||||
"text/template"
|
"text/template"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -65,46 +64,8 @@ type ProviderMetadata struct {
|
|||||||
// It integrates with various OIDC providers, manages sessions, caches tokens, and handles
|
// It integrates with various OIDC providers, manages sessions, caches tokens, and handles
|
||||||
// the complete authentication flow. It's designed to work seamlessly with Traefik's
|
// the complete authentication flow. It's designed to work seamlessly with Traefik's
|
||||||
// plugin system and provides flexible configuration options.
|
// plugin system and provides flexible configuration options.
|
||||||
// MetadataSnapshot is an immutable bundle of provider-metadata URLs that the
|
|
||||||
// plugin needs on the hot request path. Published atomically via
|
|
||||||
// TraefikOidc.metadataSnapshot; readers do exactly one atomic.Value.Load to
|
|
||||||
// access all fields. Replaces 3 per-request metadataMu.RLock acquisitions
|
|
||||||
// in middleware.ServeHTTP + token_manager paths, each of which paid
|
|
||||||
// 1-5ms of Yaegi-dispatch overhead.
|
|
||||||
//
|
|
||||||
// The fields are a strict subset of the metadataMu-guarded TraefikOidc
|
|
||||||
// fields; the legacy fields are still written under metadataMu for
|
|
||||||
// less-frequent code paths that have not been migrated.
|
|
||||||
type MetadataSnapshot struct {
|
|
||||||
IssuerURL string
|
|
||||||
JWKSURL string
|
|
||||||
TokenURL string
|
|
||||||
AuthURL string
|
|
||||||
RevocationURL string
|
|
||||||
EndSessionURL string
|
|
||||||
IntrospectionURL string
|
|
||||||
RegistrationURL string
|
|
||||||
}
|
|
||||||
|
|
||||||
type TraefikOidc struct {
|
type TraefikOidc struct {
|
||||||
// metadataSnapshot atomically publishes the read-mostly URL bundle.
|
lastMetadataRetryTime time.Time
|
||||||
// Hot-path readers (middleware.ServeHTTP, token verification) load it
|
|
||||||
// directly; less-frequent paths still acquire metadataMu.RLock and
|
|
||||||
// read the individual fields below.
|
|
||||||
metadataSnapshot atomic.Value
|
|
||||||
// lastMetadataRetryNano is the UnixNano timestamp of the last metadata
|
|
||||||
// recovery attempt. Stored atomically so the hot ServeHTTP path can
|
|
||||||
// throttle retries without acquiring metadataRetryMutex on every request.
|
|
||||||
lastMetadataRetryNano int64
|
|
||||||
// firstRequestStarted is 0 until the very first non-health request fires
|
|
||||||
// the background-task bootstrap; then it flips to 1 via CAS. Replaces the
|
|
||||||
// firstRequestMutex + firstRequestReceived combo which previously took
|
|
||||||
// a write lock on every non-health request forever.
|
|
||||||
firstRequestStarted int32
|
|
||||||
// metadataRefreshStartedAtomic is the CAS-only variant of the old
|
|
||||||
// metadataRefreshStarted bool. Both flags live under the same atomic so
|
|
||||||
// concurrent first-request goroutines race exactly once.
|
|
||||||
metadataRefreshStartedAtomic int32
|
|
||||||
jwkCache JWKCacheInterface
|
jwkCache JWKCacheInterface
|
||||||
jwtVerifier JWTVerifier
|
jwtVerifier JWTVerifier
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
@@ -169,13 +130,17 @@ type TraefikOidc struct {
|
|||||||
maxRefreshTokenAge time.Duration
|
maxRefreshTokenAge time.Duration
|
||||||
metadataMu sync.RWMutex
|
metadataMu sync.RWMutex
|
||||||
shutdownOnce sync.Once
|
shutdownOnce sync.Once
|
||||||
|
metadataRetryMutex sync.Mutex
|
||||||
|
firstRequestMutex sync.Mutex
|
||||||
sessionInvalidationCache CacheInterface
|
sessionInvalidationCache CacheInterface
|
||||||
refreshResultCache CacheInterface
|
refreshResultCache CacheInterface
|
||||||
minimalHeaders bool
|
minimalHeaders bool
|
||||||
stripAuthCookies bool
|
stripAuthCookies bool
|
||||||
enableBackchannelLogout bool
|
enableBackchannelLogout bool
|
||||||
enableFrontchannelLogout bool
|
enableFrontchannelLogout bool
|
||||||
|
firstRequestReceived bool
|
||||||
requireTokenIntrospection bool
|
requireTokenIntrospection bool
|
||||||
|
metadataRefreshStarted bool
|
||||||
allowPrivateIPAddresses bool
|
allowPrivateIPAddresses bool
|
||||||
disableReplayDetection bool
|
disableReplayDetection bool
|
||||||
allowOpaqueTokens bool
|
allowOpaqueTokens bool
|
||||||
@@ -184,6 +149,7 @@ type TraefikOidc struct {
|
|||||||
enablePKCE bool
|
enablePKCE bool
|
||||||
forceHTTPS bool
|
forceHTTPS bool
|
||||||
suppressDiagnosticLogs bool
|
suppressDiagnosticLogs bool
|
||||||
|
trustForwardedURI bool
|
||||||
|
|
||||||
// Bearer-auth runtime state (populated only when EnableBearerAuth=true).
|
// Bearer-auth runtime state (populated only when EnableBearerAuth=true).
|
||||||
bearerIdentifierClaim string
|
bearerIdentifierClaim string
|
||||||
|
|||||||
@@ -14,19 +14,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// metadataSnap returns the most recently published *MetadataSnapshot, or nil
|
|
||||||
// if metadata has not yet been resolved. Single atomic.Value.Load — the hot
|
|
||||||
// ServeHTTP path uses this instead of acquiring metadataMu.RLock, which under
|
|
||||||
// Yaegi pays 1-5ms of interpreter-dispatch overhead per acquisition.
|
|
||||||
func (t *TraefikOidc) metadataSnap() *MetadataSnapshot {
|
|
||||||
v := t.metadataSnapshot.Load()
|
|
||||||
if v == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
s, _ := v.(*MetadataSnapshot)
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// safeLogDebug provides nil-safe logging for debug messages
|
// safeLogDebug provides nil-safe logging for debug messages
|
||||||
func (t *TraefikOidc) safeLogDebug(msg string) {
|
func (t *TraefikOidc) safeLogDebug(msg string) {
|
||||||
if t.logger != nil {
|
if t.logger != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user