Skip to content

Commit d157ab1

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 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
1 parent 428e26d commit d157ab1

8 files changed

Lines changed: 548 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: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
package api
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"net/http"
7+
"strings"
8+
"sync"
9+
"time"
10+
11+
"github.com/MicahParks/keyfunc/v3"
12+
"github.com/golang-jwt/jwt/v5"
13+
log "github.com/sirupsen/logrus"
14+
15+
"github.com/semaphoreui/semaphore/api/helpers"
16+
"github.com/semaphoreui/semaphore/db"
17+
"github.com/semaphoreui/semaphore/util"
18+
)
19+
20+
var (
21+
globalKeyfunc keyfunc.Keyfunc
22+
globalKeyfuncMu sync.RWMutex
23+
globalJWTParser *jwt.Parser
24+
)
25+
26+
func initJWKSCache(jwksURL string) {
27+
if !strings.HasPrefix(jwksURL, "https://") {
28+
log.Warn("JWT JWKS URL is not HTTPS: ", jwksURL)
29+
}
30+
31+
globalJWTParser = newJWTParser(util.Config.Auth.JWT)
32+
33+
kf, err := fetchJWKS(jwksURL)
34+
if err != nil {
35+
log.Errorf("JWKS initial fetch from %s failed: %v — JWT auth unavailable until JWKS is fetched", jwksURL, err)
36+
go retryJWKSFetch(jwksURL)
37+
return
38+
}
39+
40+
globalKeyfuncMu.Lock()
41+
globalKeyfunc = kf
42+
globalKeyfuncMu.Unlock()
43+
log.Info("JWKS loaded from ", jwksURL)
44+
}
45+
46+
// fetchJWKS fetches the JWKS from the given URL. The initial HTTP fetch runs
47+
// synchronously; if it succeeds, background refresh continues automatically.
48+
func fetchJWKS(jwksURL string) (keyfunc.Keyfunc, error) {
49+
type result struct {
50+
kf keyfunc.Keyfunc
51+
err error
52+
}
53+
ch := make(chan result, 1) // buffered so the goroutine won't leak on timeout
54+
go func() {
55+
kf, err := keyfunc.NewDefault([]string{jwksURL})
56+
ch <- result{kf, err}
57+
}()
58+
select {
59+
case r := <-ch:
60+
return r.kf, r.err
61+
case <-time.After(15 * time.Second):
62+
return nil, fmt.Errorf("timeout fetching JWKS from %s", jwksURL)
63+
}
64+
}
65+
66+
// retryJWKSFetch retries JWKS fetch indefinitely with backoff. It never gives
67+
// up because the JWKS endpoint may become reachable after the server starts
68+
// (e.g. DNS not yet propagated inside a pod).
69+
func retryJWKSFetch(jwksURL string) {
70+
delays := []time.Duration{5 * time.Second, 10 * time.Second, 30 * time.Second, time.Minute}
71+
72+
for attempt := 0; ; attempt++ {
73+
delay := delays[len(delays)-1]
74+
if attempt < len(delays) {
75+
delay = delays[attempt]
76+
}
77+
time.Sleep(delay)
78+
79+
log.Infof("JWKS retry attempt %d for %s", attempt+1, jwksURL)
80+
kf, err := fetchJWKS(jwksURL)
81+
if err != nil {
82+
log.Errorf("JWKS retry %d failed: %v", attempt+1, err)
83+
continue
84+
}
85+
86+
globalKeyfuncMu.Lock()
87+
globalKeyfunc = kf
88+
globalKeyfuncMu.Unlock()
89+
log.Info("JWKS loaded from ", jwksURL)
90+
return
91+
}
92+
}
93+
94+
func newJWTParser(config *util.JWTAuthConfig) *jwt.Parser {
95+
opts := []jwt.ParserOption{
96+
jwt.WithValidMethods([]string{"ES256", "ES384", "ES512", "RS256", "RS384", "RS512"}),
97+
jwt.WithExpirationRequired(),
98+
}
99+
100+
if config.Audience != "" {
101+
opts = append(opts, jwt.WithAudience(config.Audience))
102+
}
103+
if config.Issuer != "" {
104+
opts = append(opts, jwt.WithIssuer(config.Issuer))
105+
}
106+
107+
return jwt.NewParser(opts...)
108+
}
109+
110+
func validateProxyJWT(tokenString string) (map[string]any, error) {
111+
globalKeyfuncMu.RLock()
112+
kf := globalKeyfunc
113+
globalKeyfuncMu.RUnlock()
114+
115+
if kf == nil {
116+
return nil, fmt.Errorf("JWKS not yet available — JWT auth is temporarily unavailable")
117+
}
118+
119+
token, err := globalJWTParser.Parse(tokenString, kf.Keyfunc)
120+
if err != nil {
121+
// Parse without verification solely to extract iss/aud for operator-facing
122+
// log messages. The token has already been rejected above.
123+
unverified, _, parseErr := jwt.NewParser(jwt.WithoutClaimsValidation()).ParseUnverified(tokenString, jwt.MapClaims{})
124+
if parseErr == nil {
125+
if claims, ok := unverified.Claims.(jwt.MapClaims); ok {
126+
return nil, fmt.Errorf("JWT validation failed (iss=%v aud=%v): %w",
127+
claims["iss"], claims["aud"], err)
128+
}
129+
}
130+
return nil, fmt.Errorf("JWT validation failed: %w", err)
131+
}
132+
133+
claims, ok := token.Claims.(jwt.MapClaims)
134+
if !ok {
135+
return nil, fmt.Errorf("unexpected claims type")
136+
}
137+
138+
return claims, nil
139+
}
140+
141+
func authenticateByJWT(r *http.Request) (int, error) {
142+
config := util.Config.Auth.JWT
143+
144+
tokenString := r.Header.Get(config.GetHeader())
145+
if tokenString == "" {
146+
return 0, fmt.Errorf("no JWT in header %s", config.GetHeader())
147+
}
148+
149+
claims, err := validateProxyJWT(tokenString)
150+
if err != nil {
151+
return 0, err
152+
}
153+
154+
prepareClaims(claims)
155+
parsed, err := parseClaims(claims, config)
156+
if err != nil {
157+
return 0, fmt.Errorf("extract claims: %w", err)
158+
}
159+
160+
store := helpers.Store(r)
161+
162+
user, err := store.GetUserByLoginOrEmail("", parsed.email)
163+
164+
if errors.Is(err, db.ErrNotFound) {
165+
user = db.User{
166+
Username: parsed.username,
167+
Name: parsed.name,
168+
Email: parsed.email,
169+
External: true,
170+
}
171+
user, err = store.CreateUserWithoutPassword(user)
172+
}
173+
174+
if err != nil {
175+
return 0, fmt.Errorf("JWT user lookup/creation: %w", err)
176+
}
177+
178+
if !user.External {
179+
return 0, fmt.Errorf("JWT user %q conflicts with local user", user.Email)
180+
}
181+
182+
return user.ID, nil
183+
}

0 commit comments

Comments
 (0)