-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Expand file tree
/
Copy pathjwt_auth.go
More file actions
130 lines (105 loc) · 3.36 KB
/
jwt_auth.go
File metadata and controls
130 lines (105 loc) · 3.36 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
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})
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
}