Skip to content

Commit a517d3b

Browse files
committed
feat(auth): add JWT proxy authentication for reverse proxy setups
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
1 parent 428e26d commit a517d3b

8 files changed

Lines changed: 495 additions & 1 deletion

File tree

api/auth.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,9 +213,25 @@ func authenticationHandler(w http.ResponseWriter, r *http.Request) (ok bool, req
213213

214214
req = r
215215

216+
var jwtConfig *util.JWTAuthConfig
217+
if util.Config.Auth != nil {
218+
jwtConfig = util.Config.Auth.JWT
219+
}
216220
authHeader := strings.ToLower(r.Header.Get("authorization"))
217221

218-
if len(authHeader) > 0 && strings.Contains(authHeader, "bearer") {
222+
if jwtConfig != nil && jwtConfig.Enabled && r.Header.Get(jwtConfig.GetHeader()) != "" {
223+
// JWT proxy auth: if the header is present, commit to this path.
224+
var err error
225+
userID, err = authenticateByJWT(r)
226+
if err != nil {
227+
log.WithFields(log.Fields{
228+
"path": r.URL.Path,
229+
"remote": r.RemoteAddr,
230+
}).Warn("JWT auth failed: ", err)
231+
w.WriteHeader(http.StatusUnauthorized)
232+
return
233+
}
234+
} else if len(authHeader) > 0 && strings.Contains(authHeader, "bearer") {
219235
token, err := helpers.Store(r).GetAPIToken(strings.Replace(authHeader, "bearer ", "", 1))
220236

221237
if err != nil {

api/jwt_auth.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package api
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"net/http"
8+
"strings"
9+
10+
"github.com/MicahParks/keyfunc/v3"
11+
"github.com/golang-jwt/jwt/v5"
12+
log "github.com/sirupsen/logrus"
13+
14+
"github.com/semaphoreui/semaphore/api/helpers"
15+
"github.com/semaphoreui/semaphore/db"
16+
"github.com/semaphoreui/semaphore/util"
17+
)
18+
19+
var (
20+
globalKeyfunc keyfunc.Keyfunc
21+
globalJWTParser *jwt.Parser
22+
)
23+
24+
// initJWKSCache creates the JWT parser and starts keyfunc's JWKS client.
25+
// keyfunc.NewDefaultCtx performs an initial HTTP fetch (up to 1 min timeout)
26+
// but with NoErrorReturnFirstHTTPReq=true it returns successfully even if the
27+
// endpoint is unreachable. Its built-in refresh goroutine retries hourly.
28+
func initJWKSCache(jwksURL string) {
29+
if !strings.HasPrefix(jwksURL, "https://") {
30+
log.Warn("JWT JWKS URL is not HTTPS: ", jwksURL)
31+
}
32+
33+
globalJWTParser = newJWTParser(util.Config.Auth.JWT)
34+
35+
kf, err := keyfunc.NewDefaultCtx(context.Background(), []string{jwksURL})
36+
if err != nil {
37+
log.Errorf("JWKS setup for %s failed: %v — JWT auth will not work", jwksURL, err)
38+
return
39+
}
40+
41+
globalKeyfunc = kf
42+
log.Info("JWKS initialized from ", jwksURL)
43+
}
44+
45+
func newJWTParser(config *util.JWTAuthConfig) *jwt.Parser {
46+
opts := []jwt.ParserOption{
47+
jwt.WithValidMethods([]string{"ES256", "ES384", "ES512", "RS256", "RS384", "RS512"}),
48+
jwt.WithExpirationRequired(),
49+
}
50+
51+
if config.Audience != "" {
52+
opts = append(opts, jwt.WithAudience(config.Audience))
53+
}
54+
if config.Issuer != "" {
55+
opts = append(opts, jwt.WithIssuer(config.Issuer))
56+
}
57+
58+
return jwt.NewParser(opts...)
59+
}
60+
61+
func validateProxyJWT(tokenString string) (map[string]any, error) {
62+
if globalKeyfunc == nil {
63+
return nil, fmt.Errorf("JWKS not available — JWT auth is not configured")
64+
}
65+
66+
token, err := globalJWTParser.Parse(tokenString, globalKeyfunc.Keyfunc)
67+
if err != nil {
68+
// Parse without verification solely to extract iss/aud for operator-facing
69+
// log messages. The token has already been rejected above.
70+
unverified, _, parseErr := jwt.NewParser(jwt.WithoutClaimsValidation()).ParseUnverified(tokenString, jwt.MapClaims{})
71+
if parseErr == nil {
72+
if claims, ok := unverified.Claims.(jwt.MapClaims); ok {
73+
return nil, fmt.Errorf("JWT validation failed (iss=%v aud=%v): %w",
74+
claims["iss"], claims["aud"], err)
75+
}
76+
}
77+
return nil, fmt.Errorf("JWT validation failed: %w", err)
78+
}
79+
80+
claims, ok := token.Claims.(jwt.MapClaims)
81+
if !ok {
82+
return nil, fmt.Errorf("unexpected claims type")
83+
}
84+
85+
return claims, nil
86+
}
87+
88+
func authenticateByJWT(r *http.Request) (int, error) {
89+
config := util.Config.Auth.JWT
90+
91+
tokenString := r.Header.Get(config.GetHeader())
92+
if tokenString == "" {
93+
return 0, fmt.Errorf("no JWT in header %s", config.GetHeader())
94+
}
95+
96+
claims, err := validateProxyJWT(tokenString)
97+
if err != nil {
98+
return 0, err
99+
}
100+
101+
prepareClaims(claims)
102+
parsed, err := parseClaims(claims, config)
103+
if err != nil {
104+
return 0, fmt.Errorf("extract claims: %w", err)
105+
}
106+
107+
store := helpers.Store(r)
108+
109+
user, err := store.GetUserByLoginOrEmail("", parsed.email)
110+
111+
if errors.Is(err, db.ErrNotFound) {
112+
user = db.User{
113+
Username: parsed.username,
114+
Name: parsed.name,
115+
Email: parsed.email,
116+
External: true,
117+
}
118+
user, err = store.CreateUserWithoutPassword(user)
119+
}
120+
121+
if err != nil {
122+
return 0, fmt.Errorf("JWT user lookup/creation: %w", err)
123+
}
124+
125+
if !user.External {
126+
return 0, fmt.Errorf("JWT user %q conflicts with local user", user.Email)
127+
}
128+
129+
return user.ID, nil
130+
}

0 commit comments

Comments
 (0)