Skip to content

Commit 5e4b3eb

Browse files
committed
change: verify claims in jwt
Previously we did non require any claims in the token and only verified a subset of them if present. Now we make the existence of most claims mandatory. Further claims can be enforced with the newly added options
1 parent 086c76f commit 5e4b3eb

7 files changed

Lines changed: 307 additions & 152 deletions

File tree

auth/auth.go

Lines changed: 70 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,29 @@ import (
99
"errors"
1010
"fmt"
1111
"net/url"
12+
"slices"
1213
"strings"
14+
"time"
1315

1416
"github.com/Nerzal/gocloak/v13"
17+
"github.com/Nerzal/gocloak/v13/pkg/jwx"
1518
"github.com/golang-jwt/jwt/v5"
1619
)
1720

1821
// KeycloakAuthorizer is used to validate if JWT has a correct signature and is valid and returns keycloak claims
1922
type KeycloakAuthorizer struct {
20-
realmInfo KeycloakRealmInfo
21-
client *gocloak.GoCloak
23+
realmInfo KeycloakRealmInfo
24+
client *gocloak.GoCloak
25+
validator *jwt.Validator
26+
validatorOptions []jwt.ParserOption
27+
validMethods []string
2228
}
2329

2430
// KeycloakRealmInfo provides keycloak realm and server information
2531
type KeycloakRealmInfo struct {
2632
RealmId string // RealmId is the realm name that is passed to services via env vars
2733
AuthServerInternalUrl string // AuthServerInternalUrl should point to keycloak auth server on internal (not public) network, e.g. http://keycloak:8080/auth; used for contacting keycloak for realm certificate for JWT
34+
AuthServerPublicUrl string // AuthServerPublicUrl should point to keycloak auth server on public network; used for validating issuer claim in JWT
2835
}
2936

3037
func (i *KeycloakRealmInfo) validate() error {
@@ -39,6 +46,11 @@ func (i *KeycloakRealmInfo) validate() error {
3946
errs = append(errs, fmt.Errorf("couldn't parse auth server internal url: %w", err))
4047
}
4148

49+
_, err = url.ParseRequestURI(i.AuthServerPublicUrl)
50+
if err != nil {
51+
errs = append(errs, fmt.Errorf("couldn't parse auth server public url: %w", err))
52+
}
53+
4254
if len(errs) > 0 {
4355
return fmt.Errorf("\n%w", errors.Join(errs...))
4456
}
@@ -64,6 +76,13 @@ func NewKeycloakAuthorizer(realmInfo KeycloakRealmInfo, options ...func(*Keycloa
6476
o(authorizer)
6577
}
6678

79+
authorizer.validatorOptions = append(authorizer.validatorOptions,
80+
jwt.WithIssuer(fmt.Sprintf("%s/realms/%s", realmInfo.AuthServerPublicUrl, realmInfo.RealmId)),
81+
jwt.WithExpirationRequired(),
82+
jwt.WithIssuedAt(),
83+
)
84+
authorizer.validator = jwt.NewValidator(authorizer.validatorOptions...)
85+
6786
return authorizer, nil
6887
}
6988

@@ -73,6 +92,30 @@ func ConfigureGoCloak(f func(c *gocloak.GoCloak)) func(a *KeycloakAuthorizer) {
7392
}
7493
}
7594

95+
// WithValidMethods sets the allowed signing methods for the JWT parser.
96+
// Empty list means all signing methods permitted by gocloak are allowed.
97+
func WithValidMethods(methods ...string) func(a *KeycloakAuthorizer) {
98+
return func(a *KeycloakAuthorizer) {
99+
a.validMethods = methods
100+
}
101+
}
102+
103+
// WithLeeway sets the leeway window for the JWT parser.
104+
// This is used to account for clock skew when validating the token's claims.
105+
func WithLeeway(leeway time.Duration) func(a *KeycloakAuthorizer) {
106+
return func(a *KeycloakAuthorizer) {
107+
a.validatorOptions = append(a.validatorOptions, jwt.WithLeeway(leeway))
108+
}
109+
}
110+
111+
// WithAudience configures the validator to require any of the specified audiences in the `aud` claim.
112+
// Validation will fail if the audience is not listed in the token or the `aud` claim is missing.
113+
func WithAudience(audience ...string) func(a *KeycloakAuthorizer) {
114+
return func(a *KeycloakAuthorizer) {
115+
a.validatorOptions = append(a.validatorOptions, jwt.WithAudience(audience...))
116+
}
117+
}
118+
76119
func (a *KeycloakAuthorizer) parseAuthorizationHeader(authorizationHeader string) (string, error) {
77120
fields := strings.Fields(authorizationHeader)
78121
if len(fields) != 2 {
@@ -131,27 +174,47 @@ func (a *KeycloakAuthorizer) ParseAuthorizationHeader(ctx context.Context, authH
131174
func (a *KeycloakAuthorizer) ParseJWT(ctx context.Context, token string) (UserContext, error) {
132175
type customClaims struct {
133176
jwt.RegisteredClaims
134-
UserId string `json:"sub"`
135177
Email string `json:"email"`
136178
UserName string `json:"preferred_username"`
137179
Roles []string `json:"roles"`
138180
Groups []string `json:"groups"`
139181
AllowedOrigins []string `json:"allowed-origins"`
140182
}
141183

142-
jwtToken, _, err := jwt.NewParser().ParseUnverified(token, &customClaims{})
184+
tokenHeader, err := jwx.DecodeAccessTokenHeader(token)
143185
if err != nil {
144-
return UserContext{}, fmt.Errorf("parsing of token failed: %w", err)
186+
return UserContext{}, fmt.Errorf("could not decode token header: %w", err)
187+
}
188+
if len(a.validMethods) > 0 {
189+
if !slices.Contains(a.validMethods, tokenHeader.Alg) {
190+
return UserContext{}, fmt.Errorf("invalid token signing method: %s", tokenHeader.Alg)
191+
}
145192
}
146-
claims := jwtToken.Claims.(*customClaims)
147193

194+
// verify token signature
148195
if _, _, err := a.client.DecodeAccessToken(ctx, token, a.realmInfo.RealmId); err != nil {
149196
return UserContext{}, fmt.Errorf("validation of token failed: %w", err)
150197
}
151198

199+
// extract claims from token; signature is validated above; claims are validated below
200+
jwtToken, _, err := jwt.NewParser(jwt.WithoutClaimsValidation()).ParseUnverified(token, &customClaims{})
201+
if err != nil {
202+
return UserContext{}, fmt.Errorf("parsing of token failed: %w", err)
203+
}
204+
claims := jwtToken.Claims.(*customClaims)
205+
206+
// verify claims
207+
err = a.validator.Validate(claims)
208+
if err != nil {
209+
return UserContext{}, fmt.Errorf("validation of token claims failed: %w", err)
210+
}
211+
if claims.Subject == "" { // [jwt.Validator] does not support just checking for non-empty subject claim, so we check it here
212+
return UserContext{}, fmt.Errorf("validation of token claims failed: missing subject claim")
213+
}
214+
152215
return UserContext{
153216
Realm: a.realmInfo.RealmId,
154-
UserID: claims.UserId,
217+
UserID: claims.Subject,
155218
UserName: claims.UserName,
156219
EmailAddress: claims.Email,
157220
Roles: claims.Roles,

auth/auth_test.go

Lines changed: 130 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"fmt"
1010
"testing"
1111

12+
"github.com/golang-jwt/jwt/v5"
1213
"github.com/stretchr/testify/assert"
1314
"github.com/stretchr/testify/require"
1415
)
@@ -32,16 +33,28 @@ func TestNewKeycloakAuthorizer(t *testing.T) {
3233
}
3334
t.Run("internal", func(t *testing.T) {
3435
for _, test := range tests {
35-
t.Run(test, func(t *testing.T) {
36+
t.Run("internal url "+test, func(t *testing.T) {
3637
authorizer, err := NewKeycloakAuthorizer(KeycloakRealmInfo{
3738
AuthServerInternalUrl: test,
3839
RealmId: validRealm,
40+
AuthServerPublicUrl: validPublicUrl,
3941
})
4042

4143
assert.ErrorContains(t, err, "invalid realm info")
4244
assert.ErrorContains(t, err, "couldn't parse auth server internal url")
4345
assert.Nil(t, authorizer)
4446
})
47+
t.Run("public url "+test, func(t *testing.T) {
48+
authorizer, err := NewKeycloakAuthorizer(KeycloakRealmInfo{
49+
AuthServerPublicUrl: test,
50+
RealmId: validRealm,
51+
AuthServerInternalUrl: validInternalUrl,
52+
})
53+
54+
assert.ErrorContains(t, err, "invalid realm info")
55+
assert.ErrorContains(t, err, "couldn't parse auth server public url")
56+
assert.Nil(t, authorizer)
57+
})
4558
}
4659
})
4760
})
@@ -50,6 +63,7 @@ func TestNewKeycloakAuthorizer(t *testing.T) {
5063
authorizer, err := NewKeycloakAuthorizer(KeycloakRealmInfo{
5164
RealmId: validRealm,
5265
AuthServerInternalUrl: validInternalUrl,
66+
AuthServerPublicUrl: validPublicUrl,
5367
})
5468

5569
assert.NoError(t, err)
@@ -58,49 +72,133 @@ func TestNewKeycloakAuthorizer(t *testing.T) {
5872
}
5973

6074
func TestParseJWT(t *testing.T) {
75+
tokenIssuer, err := NewTokenIssuer(publicKeyALG, publicKeyID)
76+
require.NoError(t, err)
77+
authServer := FakeAuthServer(t, tokenIssuer)
78+
6179
authorizer, err := NewKeycloakAuthorizer(KeycloakRealmInfo{
6280
RealmId: validRealm,
63-
AuthServerInternalUrl: validInternalUrl,
64-
})
81+
AuthServerInternalUrl: authServer.URL,
82+
AuthServerPublicUrl: validPublicUrl,
83+
},
84+
WithValidMethods("RS256"),
85+
WithAudience(validAudience, "other valid audience"),
86+
)
6587
require.NoError(t, err)
6688
require.NotNil(t, authorizer)
6789

68-
FakeCertResponse(t, authorizer)
69-
7090
t.Run("Wrong algorithm", func(t *testing.T) {
71-
userContext, err := authorizer.ParseJWT(context.Background(), invalidAlgorithmToken)
91+
token := tokenIssuer.GetTokenWithAlg(t, jwt.SigningMethodHS256.Alg(), validClaims())
7292

73-
assert.ErrorContains(t, err, "validation of token failed")
74-
assert.ErrorContains(t, err, "cannot find a key to decode the token")
93+
userContext, err := authorizer.ParseJWT(context.Background(), token)
94+
95+
assert.ErrorContains(t, err, "invalid token signing method")
7596
assert.Zero(t, userContext)
7697
})
7798

7899
t.Run("Wrong signature", func(t *testing.T) {
79-
userContext, err := authorizer.ParseJWT(context.Background(), invalidSignatureToken)
100+
token := tokenIssuer.GetToken(t, jwt.MapClaims{
101+
"iss": validPublicUrl + "/realms/" + validRealm,
102+
})
103+
token += "XX" // malformed signature
104+
105+
userContext, err := authorizer.ParseJWT(context.Background(), token)
80106

81107
assert.ErrorContains(t, err, "validation of token failed")
82108
assert.ErrorContains(t, err, "crypto/rsa: verification error")
83109
assert.Zero(t, userContext)
84110
})
85111

86112
t.Run("Expired token", func(t *testing.T) {
87-
userContext, err := authorizer.ParseJWT(context.Background(), expiredToken)
113+
claims := validClaims()
114+
claims["exp"] = 1500000000
115+
token := tokenIssuer.GetToken(t, claims)
88116

89-
assert.ErrorContains(t, err, "validation of token failed")
117+
userContext, err := authorizer.ParseJWT(context.Background(), token)
118+
119+
assert.ErrorContains(t, err, "token has invalid claims")
90120
assert.ErrorContains(t, err, "token is expired")
91121
assert.Zero(t, userContext)
92122
})
93123

124+
t.Run("missing expiration", func(t *testing.T) {
125+
claims := validClaims()
126+
delete(claims, "exp")
127+
token := tokenIssuer.GetToken(t, claims)
128+
129+
userContext, err := authorizer.ParseJWT(context.Background(), token)
130+
131+
assert.ErrorContains(t, err, "validation of token claims failed")
132+
assert.ErrorContains(t, err, "exp claim is required")
133+
assert.Zero(t, userContext)
134+
})
135+
136+
t.Run("invalid issuedAt", func(t *testing.T) {
137+
claims := validClaims()
138+
claims["iat"] = 9999999999
139+
token := tokenIssuer.GetToken(t, claims)
140+
141+
userContext, err := authorizer.ParseJWT(context.Background(), token)
142+
143+
assert.ErrorContains(t, err, "validation of token claims failed")
144+
assert.ErrorContains(t, err, "token used before issued")
145+
assert.Zero(t, userContext)
146+
})
147+
148+
t.Run("Wrong issuer", func(t *testing.T) {
149+
claims := validClaims()
150+
claims["iss"] = "invalid_issuer"
151+
token := tokenIssuer.GetToken(t, claims)
152+
153+
userContext, err := authorizer.ParseJWT(context.Background(), token)
154+
155+
assert.ErrorContains(t, err, "validation of token claims failed")
156+
assert.ErrorContains(t, err, "token has invalid issuer")
157+
assert.Zero(t, userContext)
158+
})
159+
160+
t.Run("Missing subject", func(t *testing.T) {
161+
claims := validClaims()
162+
delete(claims, "sub")
163+
token := tokenIssuer.GetToken(t, claims)
164+
165+
userContext, err := authorizer.ParseJWT(context.Background(), token)
166+
167+
assert.ErrorContains(t, err, "validation of token claims failed")
168+
assert.ErrorContains(t, err, "missing subject claim")
169+
assert.Zero(t, userContext)
170+
})
171+
172+
t.Run("Wrong audience", func(t *testing.T) {
173+
claims := validClaims()
174+
claims["aud"] = "invalid_audience"
175+
token := tokenIssuer.GetToken(t, claims)
176+
177+
userContext, err := authorizer.ParseJWT(context.Background(), token)
178+
179+
assert.ErrorContains(t, err, "validation of token claims failed")
180+
assert.ErrorContains(t, err, "token has invalid audience")
181+
assert.Zero(t, userContext)
182+
})
183+
94184
t.Run("Invalid claims", func(t *testing.T) {
95-
userContext, err := authorizer.ParseJWT(context.Background(), invalidClaimsToken)
185+
claims := validClaims()
186+
claims["email"] = 12345
187+
claims["roles"] = 1
188+
claims["groups"] = 2
189+
token := tokenIssuer.GetToken(t, claims)
190+
191+
userContext, err := authorizer.ParseJWT(context.Background(), token)
96192

97193
assert.ErrorContains(t, err, "parsing of token failed")
98194
assert.ErrorContains(t, err, "cannot unmarshal number")
99195
assert.Zero(t, userContext)
100196
})
101197

102198
t.Run("OK", func(t *testing.T) {
103-
userContext, err := authorizer.ParseJWT(context.Background(), validToken)
199+
token := tokenIssuer.GetToken(t, validClaims())
200+
201+
userContext, err := authorizer.ParseJWT(context.Background(), token)
104202

105203
require.NoError(t, err)
106204
require.NotZero(t, userContext)
@@ -111,19 +209,24 @@ func TestParseJWT(t *testing.T) {
111209
assert.Equal(t, "initial", userContext.UserName)
112210
assert.ElementsMatch(t, []string{"offline_access", "uma_authorization", "user", "default-roles-user-management"}, userContext.Roles)
113211
assert.ElementsMatch(t, []string{"user-management-initial"}, userContext.Groups)
212+
assert.ElementsMatch(t, []string{validOrigin}, userContext.AllowedOrigins)
114213
})
115214
}
116215

117216
func TestParseAuthorizationHeader(t *testing.T) {
217+
tokenIssuer, err := NewTokenIssuer(publicKeyALG, publicKeyID)
218+
require.NoError(t, err)
219+
authServer := FakeAuthServer(t, tokenIssuer)
220+
validToken := tokenIssuer.GetToken(t, validClaims())
221+
118222
authorizer, err := NewKeycloakAuthorizer(KeycloakRealmInfo{
119223
RealmId: validRealm,
120-
AuthServerInternalUrl: validInternalUrl,
224+
AuthServerInternalUrl: authServer.URL,
225+
AuthServerPublicUrl: validPublicUrl,
121226
})
122227
require.NoError(t, err)
123228
require.NotNil(t, authorizer)
124229

125-
FakeCertResponse(t, authorizer)
126-
127230
t.Run("Invalid fields", func(t *testing.T) {
128231
tests := []string{
129232
"",
@@ -148,6 +251,10 @@ func TestParseAuthorizationHeader(t *testing.T) {
148251
})
149252

150253
t.Run("Invalid token", func(t *testing.T) {
254+
expiredClaims := validClaims()
255+
expiredClaims["exp"] = 1500000000
256+
expiredToken := tokenIssuer.GetToken(t, expiredClaims)
257+
151258
userContext, err := authorizer.ParseAuthorizationHeader(context.Background(), "bearer "+expiredToken)
152259

153260
assert.ErrorContains(t, err, "validation of token failed")
@@ -169,15 +276,19 @@ func TestParseAuthorizationHeader(t *testing.T) {
169276
}
170277

171278
func TestParseRequest(t *testing.T) {
279+
tokenIssuer, err := NewTokenIssuer(publicKeyALG, publicKeyID)
280+
require.NoError(t, err)
281+
authServer := FakeAuthServer(t, tokenIssuer)
282+
validToken := tokenIssuer.GetToken(t, validClaims())
283+
172284
authorizer, err := NewKeycloakAuthorizer(KeycloakRealmInfo{
173285
RealmId: validRealm,
174-
AuthServerInternalUrl: validInternalUrl,
286+
AuthServerInternalUrl: authServer.URL,
287+
AuthServerPublicUrl: validPublicUrl,
175288
})
176289
require.NoError(t, err)
177290
require.NotNil(t, authorizer)
178291

179-
FakeCertResponse(t, authorizer)
180-
181292
t.Run("Invalid authorization header", func(t *testing.T) {
182293
userContext, err := authorizer.ParseRequest(context.Background(), "invalid", validOrigin)
183294

0 commit comments

Comments
 (0)