feat(auth): add JWT proxy authentication for reverse proxy setups#3719
feat(auth): add JWT proxy authentication for reverse proxy setups#3719abh wants to merge 1 commit intosemaphoreui:developfrom
Conversation
|
for @copilot -- Design notes for reviewers:
|
There was a problem hiding this comment.
Pull request overview
Adds a new authentication path for reverse-proxy setups where the proxy forwards a signed JWT in a request header, allowing Semaphore to validate identity statelessly via JWKS and auto-provision external users.
Changes:
- Introduces
JWTAuthConfigand startup validation for required JWT settings (header + JWKS URL). - Initializes a JWKS-backed keyfunc cache on API router startup and adds JWT-first auth handling in
authenticationHandler. - Implements JWT validation + external user lookup/creation, with unit tests covering common validation scenarios.
Reviewed changes
Copilot reviewed 7 out of 8 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| util/config.go | Validates required JWT auth configuration at startup when enabled. |
| util/config_auth.go | Adds JWT auth config struct and claim/header accessors. |
| api/router.go | Initializes JWKS cache when JWT auth is enabled. |
| api/auth.go | Checks JWT header first and hard-fails with 401 on JWT validation errors. |
| api/jwt_auth.go | Implements JWKS fetching/retry, JWT parser setup, token validation, and user provisioning. |
| api/jwt_auth_test.go | Adds unit tests for JWT validation behavior and config defaults. |
| go.mod | Adds JWT/JWKS dependencies. |
| go.sum | Adds checksums for new dependencies. |
Document JWT proxy authentication for reverse proxy setups (Pomerium, Cloudflare Access, etc). Covers auth flow, config file/env vars, Docker example, options table, supported algorithms, and JWKS retry. Ref: semaphoreui/semaphore#3719
When Semaphore runs behind a reverse proxy (e.g. Pomerium), the proxy authenticates users and passes identity via a signed JWT header. This adds stateless JWT validation as a new auth path, avoiding redundant OIDC configuration. Auth flow: JWT header checked first in authenticationHandler. If present and valid, user is loaded/created. If present but invalid, hard 401 (no fallthrough). If absent, existing bearer/session auth proceeds unchanged. - JWTAuthConfig in util/config_auth.go with configurable header, JWKS URL, audience, issuer, and claim mappings (implements ClaimsProvider) - Header and jwks_url must be explicitly configured; no vendor-specific defaults. Both are validated at startup when JWT auth is enabled. - JWKS via keyfunc.NewDefaultCtx: initial fetch at startup (non-blocking on failure thanks to NoErrorReturnFirstHTTPReq), with built-in hourly background refresh and rate-limited unknown-KID refresh - JWT parsing via golang-jwt/jwt/v5 with algorithm allowlist (ES256, ES384, ES512, RS256, RS384, RS512), required expiration, optional aud/iss validation - Auto-creates external users on first JWT auth (same as OIDC pattern) - Rejects JWT if email matches a local (non-external) user - JWT auth failures log request path, remote address, and on validation errors include the token's actual iss/aud values for debugging
|
I've been using (an earlier version of this) for a few days in our installation (behind Pomerium) and it's been working well |
| } | ||
| if Config.Auth.JWT.JWKSURL == "" { | ||
| panic("jwt auth is enabled but jwks_url is not configured (set auth.jwt.jwks_url or SEMAPHORE_JWT_AUTH_JWKS_URL)") | ||
| } |
There was a problem hiding this comment.
Consider validating auth.jwt.jwks_url as a well-formed URL (and optionally restricting allowed schemes) when JWT auth is enabled. As-is, a malformed JWKSURL will only be discovered later during initJWKSCache (leading to JWT auth being permanently broken while the server still starts).
| } | |
| } | |
| parsedJWKSURL, err := url.Parse(Config.Auth.JWT.JWKSURL) | |
| if err != nil || parsedJWKSURL.Scheme == "" || parsedJWKSURL.Host == "" { | |
| panic(fmt.Sprintf("auth.jwt.jwks_url must be a valid URL with scheme and host: %q", Config.Auth.JWT.JWKSURL)) | |
| } | |
| if parsedJWKSURL.Scheme != "http" && parsedJWKSURL.Scheme != "https" { | |
| panic(fmt.Sprintf("auth.jwt.jwks_url must use http or https scheme, got %q in %q", parsedJWKSURL.Scheme, Config.Auth.JWT.JWKSURL)) | |
| } |
| // initJWKSCache creates the JWT parser and starts keyfunc's JWKS client. | ||
| // keyfunc.NewDefaultCtx performs an initial HTTP fetch (up to 1 min timeout) | ||
| // but with NoErrorReturnFirstHTTPReq=true it returns successfully even if the | ||
| // endpoint is unreachable. Its built-in refresh goroutine retries hourly. | ||
| func initJWKSCache(jwksURL string) { | ||
| if !strings.HasPrefix(jwksURL, "https://") { | ||
| log.Warn("JWT JWKS URL is not HTTPS: ", jwksURL) | ||
| } | ||
|
|
||
| globalJWTParser = newJWTParser(util.Config.Auth.JWT) | ||
|
|
||
| kf, err := keyfunc.NewDefaultCtx(context.Background(), []string{jwksURL}) |
There was a problem hiding this comment.
PR description mentions background JWKS retry/backoff intervals (5s, 10s, 30s, 1m), but this implementation relies on keyfunc.NewDefaultCtx defaults without configuring those intervals. Either update the PR description to match the actual behavior or explicitly configure keyfunc options to achieve the documented retry/backoff.
When Semaphore runs behind a reverse proxy (e.g. Pomerium), the proxy
authenticates users and passes identity via a signed JWT header. This
adds stateless JWT validation as a new auth path, avoiding redundant
OIDC configuration.
Auth flow: JWT header checked first in authenticationHandler. If present
and valid, user is loaded/created. If present but invalid, hard 401 (no
fallthrough). If absent, existing bearer/session auth proceeds unchanged.
URL, audience, issuer, and claim mappings (implements ClaimsProvider)
defaults. Both are validated at startup when JWT auth is enabled.
(5s, 10s, 30s, 1m) so the server starts even if JWKS is initially
unreachable. JWT auth returns a clear error until JWKS becomes available.
ES384, ES512, RS256, RS384, RS512), required expiration, optional
aud/iss validation
errors include the token's actual iss/aud values for debugging