Skip to content

feat(auth): add JWT proxy authentication for reverse proxy setups#3719

Open
abh wants to merge 1 commit intosemaphoreui:developfrom
abh:jwt-auth
Open

feat(auth): add JWT proxy authentication for reverse proxy setups#3719
abh wants to merge 1 commit intosemaphoreui:developfrom
abh:jwt-auth

Conversation

@abh
Copy link
Copy Markdown
Contributor

@abh abh commented Mar 24, 2026

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 fetching via keyfunc.NewDefault with background retry and backoff
    (5s, 10s, 30s, 1m) so the server starts even if JWKS is initially
    unreachable. JWT auth returns a clear error until JWKS becomes available.
  • 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

Copilot AI review requested due to automatic review settings March 24, 2026 05:45
@abh
Copy link
Copy Markdown
Contributor Author

abh commented Mar 24, 2026

for @copilot --

Design notes for reviewers:

  • JWT user lookup intentionally uses errors.Is(err, db.ErrNotFound) rather
    than the OIDC pattern of treating any error as "not found". This prevents
    masking database errors by accidentally auto-creating users.

  • JWKS is managed entirely by keyfunc.NewDefaultCtx. With
    NoErrorReturnFirstHTTPReq=true (the default), it returns successfully even
    if the initial fetch fails. Its built-in refresh goroutine retries hourly
    and handles unknown-KID refresh with rate limiting. No custom retry logic
    needed.

  • Algorithm allowlist covers ES256, ES384, ES512, RS256, RS384, RS512. This
    covers all common JWT signing algorithms used by reverse proxies and
    identity providers.

  • ParseUnverified in validateProxyJWT is used solely for extracting iss/aud
    values for operator-facing error messages after the token has already been
    rejected by the verified parse.

  • JWTAuthConfig implements the existing ClaimsProvider interface and reuses
    prepareClaims/parseClaims from the OIDC flow, so claim extraction follows
    the same patterns (including Go template support in claim names).

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 JWTAuthConfig and 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.

abh added a commit to abh/semaphore-docs that referenced this pull request Mar 24, 2026
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
@abh
Copy link
Copy Markdown
Contributor Author

abh commented Mar 24, 2026

I've been using (an earlier version of this) for a few days in our installation (behind Pomerium) and it's been working well

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 8 changed files in this pull request and generated 2 comments.

}
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)")
}
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
}
}
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))
}

Copilot uses AI. Check for mistakes.
Comment on lines +24 to +35
// 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})
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants