Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion api/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,9 +213,25 @@ func authenticationHandler(w http.ResponseWriter, r *http.Request) (ok bool, req

req = r

var jwtConfig *util.JWTAuthConfig
if util.Config.Auth != nil {
jwtConfig = util.Config.Auth.JWT
}
authHeader := strings.ToLower(r.Header.Get("authorization"))

if len(authHeader) > 0 && strings.Contains(authHeader, "bearer") {
if jwtConfig != nil && jwtConfig.Enabled && r.Header.Get(jwtConfig.GetHeader()) != "" {
// JWT proxy auth: if the header is present, commit to this path.
var err error
userID, err = authenticateByJWT(r)
if err != nil {
log.WithFields(log.Fields{
"path": r.URL.Path,
"remote": r.RemoteAddr,
}).Warn("JWT auth failed: ", err)
w.WriteHeader(http.StatusUnauthorized)
return
}
} else if len(authHeader) > 0 && strings.Contains(authHeader, "bearer") {
token, err := helpers.Store(r).GetAPIToken(strings.Replace(authHeader, "bearer ", "", 1))

if err != nil {
Expand Down
130 changes: 130 additions & 0 deletions api/jwt_auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package api

import (
"context"
"errors"
"fmt"
"net/http"
"strings"

"github.com/MicahParks/keyfunc/v3"
"github.com/golang-jwt/jwt/v5"
log "github.com/sirupsen/logrus"

"github.com/semaphoreui/semaphore/api/helpers"
"github.com/semaphoreui/semaphore/db"
"github.com/semaphoreui/semaphore/util"
)

var (
globalKeyfunc keyfunc.Keyfunc
globalJWTParser *jwt.Parser
)

// 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})
Comment on lines +24 to +35
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.
if err != nil {
log.Errorf("JWKS setup for %s failed: %v — JWT auth will not work", jwksURL, err)
return
}

globalKeyfunc = kf
log.Info("JWKS initialized from ", jwksURL)
}

func newJWTParser(config *util.JWTAuthConfig) *jwt.Parser {
opts := []jwt.ParserOption{
jwt.WithValidMethods([]string{"ES256", "ES384", "ES512", "RS256", "RS384", "RS512"}),
jwt.WithExpirationRequired(),
}

if config.Audience != "" {
opts = append(opts, jwt.WithAudience(config.Audience))
}
if config.Issuer != "" {
opts = append(opts, jwt.WithIssuer(config.Issuer))
}

return jwt.NewParser(opts...)
}

func validateProxyJWT(tokenString string) (map[string]any, error) {
if globalKeyfunc == nil {
return nil, fmt.Errorf("JWKS not available — JWT auth is not configured")
}

token, err := globalJWTParser.Parse(tokenString, globalKeyfunc.Keyfunc)
if err != nil {
// Parse without verification solely to extract iss/aud for operator-facing
// log messages. The token has already been rejected above.
unverified, _, parseErr := jwt.NewParser(jwt.WithoutClaimsValidation()).ParseUnverified(tokenString, jwt.MapClaims{})
if parseErr == nil {
if claims, ok := unverified.Claims.(jwt.MapClaims); ok {
return nil, fmt.Errorf("JWT validation failed (iss=%v aud=%v): %w",
claims["iss"], claims["aud"], err)
}
}
return nil, fmt.Errorf("JWT validation failed: %w", err)
}

claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, fmt.Errorf("unexpected claims type")
}

return claims, nil
}

func authenticateByJWT(r *http.Request) (int, error) {
config := util.Config.Auth.JWT

tokenString := r.Header.Get(config.GetHeader())
if tokenString == "" {
return 0, fmt.Errorf("no JWT in header %s", config.GetHeader())
}

claims, err := validateProxyJWT(tokenString)
if err != nil {
return 0, err
}

prepareClaims(claims)
parsed, err := parseClaims(claims, config)
if err != nil {
return 0, fmt.Errorf("extract claims: %w", err)
}

store := helpers.Store(r)

user, err := store.GetUserByLoginOrEmail("", parsed.email)

if errors.Is(err, db.ErrNotFound) {
user = db.User{
Username: parsed.username,
Name: parsed.name,
Email: parsed.email,
External: true,
}
user, err = store.CreateUserWithoutPassword(user)
}

if err != nil {
return 0, fmt.Errorf("JWT user lookup/creation: %w", err)
}

if !user.External {
return 0, fmt.Errorf("JWT user %q conflicts with local user", user.Email)
}

return user.ID, nil
}
Loading
Loading