From 5c27f223aff8d3d1db1eb85d178e5ad86dcc534b Mon Sep 17 00:00:00 2001 From: Karl Persson Date: Thu, 16 May 2024 10:47:20 +0200 Subject: [PATCH] Authn: Support access token wildcard namespace (#87816) * Authn+ExtJWT: allow wildcard namespace for access tokens and restructure validation --- pkg/services/authn/authnimpl/registration.go | 5 +- pkg/services/authn/clients/ext_jwt.go | 103 +++---- pkg/services/authn/clients/ext_jwt_test.go | 278 ++++++++++--------- pkg/services/authn/clients/jwt.go | 4 - 4 files changed, 198 insertions(+), 192 deletions(-) diff --git a/pkg/services/authn/authnimpl/registration.go b/pkg/services/authn/authnimpl/registration.go index 2b9ebe5ffd1..557122aa8bc 100644 --- a/pkg/services/authn/authnimpl/registration.go +++ b/pkg/services/authn/authnimpl/registration.go @@ -18,7 +18,6 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/rendering" - "github.com/grafana/grafana/pkg/services/signingkeys" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" ) @@ -34,7 +33,7 @@ func ProvideRegistration( loginAttempts loginattempt.Service, quotaService quota.Service, authInfoService login.AuthInfoService, renderService rendering.Service, features *featuremgmt.FeatureManager, oauthTokenService oauthtoken.OAuthTokenService, - socialService social.Service, cache *remotecache.RemoteCache, signingKeysService signingkeys.Service, + socialService social.Service, cache *remotecache.RemoteCache, ldapService service.LDAP, settingsProviderService setting.Provider, ) Registration { logger := log.New("authn.registration") @@ -86,7 +85,7 @@ func ProvideRegistration( } if cfg.ExtJWTAuth.Enabled && features.IsEnabledGlobally(featuremgmt.FlagAuthAPIAccessTokenAuth) { - authnSvc.RegisterClient(clients.ProvideExtendedJWT(userService, cfg, signingKeysService)) + authnSvc.RegisterClient(clients.ProvideExtendedJWT(cfg)) } for name := range socialService.GetOAuthProviders() { diff --git a/pkg/services/authn/clients/ext_jwt.go b/pkg/services/authn/clients/ext_jwt.go index 84f82fdb26a..65d205c0078 100644 --- a/pkg/services/authn/clients/ext_jwt.go +++ b/pkg/services/authn/clients/ext_jwt.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "net/http" - "strconv" "strings" "github.com/go-jose/go-jose/v3/jwt" @@ -13,9 +12,8 @@ import ( "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/login" - "github.com/grafana/grafana/pkg/services/signingkeys" - "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util/errutil" ) var _ authn.Client = new(ExtendedJWT) @@ -26,9 +24,23 @@ const ( extJWTAccessTokenExpectAudience = "grafana" ) -func ProvideExtendedJWT(userService user.Service, cfg *setting.Cfg, - signingKeys signingkeys.Service) *ExtendedJWT { - verifier := authlib.NewAccessTokenVerifier(authlib.VerifierConfig{ +var ( + errExtJWTInvalid = errutil.Unauthorized( + "ext.jwt.invalid", errutil.WithPublicMessage("Failed to verify JWT"), + ) + errExtJWTInvalidSubject = errutil.Unauthorized( + "ext.jwt.invalid-subject", errutil.WithPublicMessage("Invalid token subject"), + ) + errExtJWTMisMatchedNamespaceClaims = errutil.Unauthorized( + "ext.jwt.namespace-mismatch", errutil.WithPublicMessage("Namespace claims didn't match between id token and access token"), + ) + errExtJWTDisallowedNamespaceClaim = errutil.Unauthorized( + "ext.jwt.namespace-disallowed", errutil.WithPublicMessage("Namespace claim doesn't allow access to requested namespace"), + ) +) + +func ProvideExtendedJWT(cfg *setting.Cfg) *ExtendedJWT { + accessTokenVerifier := authlib.NewAccessTokenVerifier(authlib.VerifierConfig{ SigningKeysURL: cfg.ExtJWTAuth.JWKSUrl, AllowedAudiences: []string{extJWTAccessTokenExpectAudience}, }) @@ -42,20 +54,15 @@ func ProvideExtendedJWT(userService user.Service, cfg *setting.Cfg, return &ExtendedJWT{ cfg: cfg, log: log.New(authn.ClientExtendedJWT), - userService: userService, - signingKeys: signingKeys, - accessTokenVerifier: verifier, namespaceMapper: request.GetNamespaceMapper(cfg), - - idTokenVerifier: idTokenVerifier, + accessTokenVerifier: accessTokenVerifier, + idTokenVerifier: idTokenVerifier, } } type ExtendedJWT struct { cfg *setting.Cfg log log.Logger - userService user.Service - signingKeys signingkeys.Service accessTokenVerifier authlib.Verifier[authlib.AccessTokenClaims] idTokenVerifier authlib.Verifier[authlib.IDTokenClaims] namespaceMapper request.NamespaceMapper @@ -67,7 +74,7 @@ func (s *ExtendedJWT) Authenticate(ctx context.Context, r *authn.Request) (*auth claims, err := s.accessTokenVerifier.Verify(ctx, jwtToken) if err != nil { s.log.Error("Failed to verify access token", "error", err) - return nil, errJWTInvalid.Errorf("Failed to verify access token: %w", err) + return nil, errExtJWTInvalid.Errorf("failed to verify access token: %w", err) } idToken := s.retrieveAuthorizationToken(r.HTTPRequest) @@ -75,7 +82,7 @@ func (s *ExtendedJWT) Authenticate(ctx context.Context, r *authn.Request) (*auth idTokenClaims, err := s.idTokenVerifier.Verify(ctx, idToken) if err != nil { s.log.Error("Failed to verify id token", "error", err) - return nil, errJWTInvalid.Errorf("Failed to verify id token: %w", err) + return nil, errExtJWTInvalid.Errorf("failed to verify id token: %w", err) } return s.authenticateAsUser(idTokenClaims, claims) @@ -88,40 +95,43 @@ func (s *ExtendedJWT) IsEnabled() bool { return s.cfg.ExtJWTAuth.Enabled } -func (s *ExtendedJWT) authenticateAsUser(idTokenClaims *authlib.Claims[authlib.IDTokenClaims], - accessTokenClaims *authlib.Claims[authlib.AccessTokenClaims]) (*authn.Identity, error) { - // compare the incoming namespace claim against what namespaceMapper returns +func (s *ExtendedJWT) authenticateAsUser( + idTokenClaims *authlib.Claims[authlib.IDTokenClaims], + accessTokenClaims *authlib.Claims[authlib.AccessTokenClaims], +) (*authn.Identity, error) { + // Only allow id tokens signed for namespace configured for this instance. if allowedNamespace := s.namespaceMapper(s.getDefaultOrgID()); idTokenClaims.Rest.Namespace != allowedNamespace { - return nil, errJWTDisallowedNamespaceClaim - } - // since id token claims can never have a wildcard ("*") namespace claim, the below comparison effectively - // disallows wildcard claims in access tokens here in Grafana (they are only meant for service layer) - if accessTokenClaims.Rest.Namespace != idTokenClaims.Rest.Namespace { - return nil, errJWTMismatchedNamespaceClaims.Errorf("id token namespace: %s, access token namespace: %s", idTokenClaims.Rest.Namespace, accessTokenClaims.Rest.Namespace) + return nil, errExtJWTDisallowedNamespaceClaim.Errorf("unexpected id token namespace: %s", idTokenClaims.Rest.Namespace) } - // Only allow access policies to impersonate - if !strings.HasPrefix(accessTokenClaims.Subject, fmt.Sprintf("%s:", authn.NamespaceAccessPolicy)) { - s.log.Error("Invalid subject", "subject", accessTokenClaims.Subject) - return nil, errJWTInvalid.Errorf("Failed to parse sub: %s", "invalid subject format") - } - // Allow only user impersonation - _, err := strconv.ParseInt(strings.TrimPrefix(idTokenClaims.Subject, fmt.Sprintf("%s:", authn.NamespaceUser)), 10, 64) - if err != nil { - s.log.Error("Failed to parse sub", "error", err) - return nil, errJWTInvalid.Errorf("Failed to parse sub: %w", err) + // Allow access tokens with either the same namespace as the validated id token namespace or wildcard (`*`). + if !accessTokenClaims.Rest.NamespaceMatches(idTokenClaims.Rest.Namespace) { + return nil, errExtJWTMisMatchedNamespaceClaims.Errorf("unexpected access token namespace: %s", accessTokenClaims.Rest.Namespace) } - id, err := authn.ParseNamespaceID(idTokenClaims.Subject) + accessID, err := authn.ParseNamespaceID(accessTokenClaims.Subject) if err != nil { - return nil, err + return nil, errExtJWTInvalidSubject.Errorf("unexpected identity: %s", accessID.String()) + } + + if !accessID.IsNamespace(authn.NamespaceAccessPolicy) { + return nil, errExtJWTInvalid.Errorf("unexpected identity: %s", accessID.String()) + } + + userID, err := authn.ParseNamespaceID(idTokenClaims.Subject) + if err != nil { + return nil, errExtJWTInvalid.Errorf("failed to parse id token subject: %w", err) + } + + if !userID.IsNamespace(authn.NamespaceUser) { + return nil, errExtJWTInvalidSubject.Errorf("unexpected identity: %s", userID.String()) } return &authn.Identity{ - ID: id, + ID: userID, OrgID: s.getDefaultOrgID(), AuthenticatedBy: login.ExtendedJWTModule, - AuthID: accessTokenClaims.Subject, + AuthID: accessID.String(), ClientParams: authn.ClientParams{ SyncPermissions: true, FetchPermissionsParams: authn.FetchPermissionsParams{ @@ -132,19 +142,18 @@ func (s *ExtendedJWT) authenticateAsUser(idTokenClaims *authlib.Claims[authlib.I } func (s *ExtendedJWT) authenticateAsService(claims *authlib.Claims[authlib.AccessTokenClaims]) (*authn.Identity, error) { - if !strings.HasPrefix(claims.Subject, fmt.Sprintf("%s:", authn.NamespaceAccessPolicy)) { - s.log.Error("Invalid subject", "subject", claims.Subject) - return nil, errJWTInvalid.Errorf("Failed to parse sub: %s", "invalid subject format") - } - - // same as asUser, disallows wildcard claims in access tokens here in Grafana (they are only meant for service layer) - if allowedNamespace := s.namespaceMapper(s.getDefaultOrgID()); claims.Rest.Namespace != allowedNamespace { - return nil, errJWTDisallowedNamespaceClaim + // Allow access tokens with that has a wildcard namespace or a namespace matching this instance. + if allowedNamespace := s.namespaceMapper(s.getDefaultOrgID()); !claims.Rest.NamespaceMatches(allowedNamespace) { + return nil, errExtJWTDisallowedNamespaceClaim.Errorf("unexpected access token namespace: %s", claims.Rest.Namespace) } id, err := authn.ParseNamespaceID(claims.Subject) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to parse access token subject: %w", err) + } + + if !id.IsNamespace(authn.NamespaceAccessPolicy) { + return nil, errExtJWTInvalidSubject.Errorf("unexpected identity: %s", id.String()) } return &authn.Identity{ diff --git a/pkg/services/authn/clients/ext_jwt_test.go b/pkg/services/authn/clients/ext_jwt_test.go index e0210deeaed..691295409ab 100644 --- a/pkg/services/authn/clients/ext_jwt_test.go +++ b/pkg/services/authn/clients/ext_jwt_test.go @@ -15,102 +15,108 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - authlib "github.com/grafana/authlib/authn" + authnlib "github.com/grafana/authlib/authn" - "github.com/grafana/grafana/pkg/models/roletype" "github.com/grafana/grafana/pkg/services/authn" - "github.com/grafana/grafana/pkg/services/signingkeys" - "github.com/grafana/grafana/pkg/services/signingkeys/signingkeystest" - "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/services/user/usertest" "github.com/grafana/grafana/pkg/setting" ) type ( - JWTAccessTokenClaims = authlib.Claims[authlib.AccessTokenClaims] - JWTIDTokenClaims = authlib.Claims[authlib.IDTokenClaims] + idTokenClaims = authnlib.Claims[authnlib.IDTokenClaims] + accessTokenClaims = authnlib.Claims[authnlib.AccessTokenClaims] ) var ( - validPayload = JWTAccessTokenClaims{ + validAccessTokenClaims = accessTokenClaims{ Claims: &jwt.Claims{ - Issuer: "http://localhost:3000", Subject: "access-policy:this-uid", - Audience: jwt.Audience{extJWTAccessTokenExpectAudience}, - ID: "1234567890", Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)), }, - Rest: authlib.AccessTokenClaims{ + Rest: authnlib.AccessTokenClaims{ Scopes: []string{"profile", "groups"}, DelegatedPermissions: []string{"dashboards:create", "folders:read", "datasources:explore", "datasources.insights:read"}, Permissions: []string{"fixed:folders:reader"}, Namespace: "default", // org ID of 1 is special and translates to default }, } - validIDPayload = JWTIDTokenClaims{ + validIDTokenClaims = idTokenClaims{ Claims: &jwt.Claims{ - Issuer: "http://localhost:3000", Subject: "user:2", - Audience: jwt.Audience{"stack:1"}, - ID: "1234567890", Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)), }, - Rest: authlib.IDTokenClaims{ + Rest: authnlib.IDTokenClaims{ AuthenticatedBy: "extended_jwt", Namespace: "default", // org ID of 1 is special and translates to default }, } - validPayloadWildcardNamespace = JWTAccessTokenClaims{ + validAcessTokenClaimsWildcard = accessTokenClaims{ Claims: &jwt.Claims{ - Issuer: "http://localhost:3000", Subject: "access-policy:this-uid", - Audience: jwt.Audience{extJWTAccessTokenExpectAudience}, - ID: "1234567890", Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)), }, - Rest: authlib.AccessTokenClaims{ + Rest: authnlib.AccessTokenClaims{ Namespace: "*", }, } - mismatchingNamespaceIDPayload = JWTIDTokenClaims{ + invalidWildcardNamespaceIDTokenClaims = idTokenClaims{ Claims: &jwt.Claims{ - Issuer: "http://localhost:3000", Subject: "user:2", - Audience: jwt.Audience{"stack:1234"}, - ID: "1234567890", Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)), }, - Rest: authlib.IDTokenClaims{ + Rest: authnlib.IDTokenClaims{ + AuthenticatedBy: "extended_jwt", + Namespace: "*", + }, + } + invalidNamespaceIDTokenClaims = idTokenClaims{ + Claims: &jwt.Claims{ + Subject: "user:2", + Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), + IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)), + }, + Rest: authnlib.IDTokenClaims{ AuthenticatedBy: "extended_jwt", Namespace: "org-2", }, } + invalidSubjectIDTokenClaims = idTokenClaims{ + Claims: &jwt.Claims{ + Subject: "service-account:2", + Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), + IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)), + }, + Rest: authnlib.IDTokenClaims{ + AuthenticatedBy: "extended_jwt", + Namespace: "default", + }, + } + pk, _ = rsa.GenerateKey(rand.Reader, 4096) ) -var _ authlib.Verifier[authlib.IDTokenClaims] = &mockIDVerifier{} +var _ authnlib.Verifier[authnlib.IDTokenClaims] = &mockIDVerifier{} type mockIDVerifier struct { - Claims JWTIDTokenClaims + Claims idTokenClaims Error error } -func (m *mockIDVerifier) Verify(ctx context.Context, token string) (*JWTIDTokenClaims, error) { +func (m *mockIDVerifier) Verify(ctx context.Context, token string) (*idTokenClaims, error) { return &m.Claims, m.Error } -var _ authlib.Verifier[authlib.AccessTokenClaims] = &mockVerifier{} +var _ authnlib.Verifier[authnlib.AccessTokenClaims] = &mockVerifier{} type mockVerifier struct { - Claims JWTAccessTokenClaims + Claims accessTokenClaims Error error } -func (m *mockVerifier) Verify(ctx context.Context, token string) (*JWTAccessTokenClaims, error) { +func (m *mockVerifier) Verify(ctx context.Context, token string) (*accessTokenClaims, error) { return &m.Claims, m.Error } @@ -136,13 +142,13 @@ func TestExtendedJWT_Test(t *testing.T) { { name: "should return true when Authorization header contains Bearer prefix", cfg: nil, - authHeaderFunc: func() string { return "Bearer " + generateToken(validPayload, pk, jose.RS256) }, + authHeaderFunc: func() string { return "Bearer " + generateToken(validAccessTokenClaims, pk, jose.RS256) }, want: true, }, { name: "should return true when Authorization header only contains the token", cfg: nil, - authHeaderFunc: func() string { return generateToken(validPayload, pk, jose.RS256) }, + authHeaderFunc: func() string { return generateToken(validAccessTokenClaims, pk, jose.RS256) }, want: true, }, { @@ -165,7 +171,7 @@ func TestExtendedJWT_Test(t *testing.T) { }, }, authHeaderFunc: func() string { - payload := validPayload + payload := validAccessTokenClaims payload.Issuer = "http://unknown-issuer" return generateToken(payload, pk, jose.RS256) }, @@ -195,18 +201,17 @@ func TestExtendedJWT_Test(t *testing.T) { func TestExtendedJWT_Authenticate(t *testing.T) { type testCase struct { name string - payload *JWTAccessTokenClaims - idPayload *JWTIDTokenClaims + accessToken *accessTokenClaims + idToken *idTokenClaims orgID int64 want *authn.Identity - initTestEnv func(env *testEnv) wantErr error } testCases := []testCase{ { - name: "successful authentication as service", - payload: &validPayload, - orgID: 1, + name: "should authenticate as service", + accessToken: &validAccessTokenClaims, + orgID: 1, want: &authn.Identity{ ID: authn.MustParseNamespaceID("access-policy:this-uid"), UID: authn.MustParseNamespaceID("access-policy:this-uid"), @@ -217,23 +222,27 @@ func TestExtendedJWT_Authenticate(t *testing.T) { SyncPermissions: true, FetchPermissionsParams: authn.FetchPermissionsParams{Roles: []string{"fixed:folders:reader"}}}, }, - wantErr: nil, }, { - name: "successful authentication as user", - payload: &validPayload, - idPayload: &validIDPayload, - orgID: 1, - initTestEnv: func(env *testEnv) { - env.userSvc.ExpectedSignedInUser = &user.SignedInUser{ - UserID: 2, - OrgID: 1, - OrgRole: roletype.RoleAdmin, - Name: "John Doe", - Email: "johndoe@grafana.com", - Login: "johndoe", - } + name: "should authenticate as service using wildcard namespace", + accessToken: &validAcessTokenClaimsWildcard, + orgID: 1, + want: &authn.Identity{ + ID: authn.MustParseNamespaceID("access-policy:this-uid"), + UID: authn.MustParseNamespaceID("access-policy:this-uid"), + OrgID: 1, + AuthenticatedBy: "extendedjwt", + AuthID: "access-policy:this-uid", + ClientParams: authn.ClientParams{ + SyncPermissions: true, + }, }, + }, + { + name: "should authenticate as user", + accessToken: &validAccessTokenClaims, + idToken: &validIDTokenClaims, + orgID: 1, want: &authn.Identity{ ID: authn.MustParseNamespaceID("user:2"), OrgID: 1, @@ -247,45 +256,49 @@ func TestExtendedJWT_Authenticate(t *testing.T) { }, }, }, - wantErr: nil, }, { - name: "fail authentication as user when access token namespace claim doesn't match id token namespace", - payload: &validPayload, - idPayload: &mismatchingNamespaceIDPayload, - orgID: 1, - initTestEnv: func(env *testEnv) { - env.userSvc.ExpectedSignedInUser = &user.SignedInUser{ - UserID: 2, - OrgID: 1, - OrgRole: roletype.RoleAdmin, - Name: "John Doe", - Email: "johndoe@grafana.com", - Login: "johndoe", - } + name: "should authenticate as user using wildcard namespace for access token", + accessToken: &validAcessTokenClaimsWildcard, + idToken: &validIDTokenClaims, + orgID: 1, + want: &authn.Identity{ + ID: authn.MustParseNamespaceID("user:2"), + OrgID: 1, + AuthenticatedBy: "extendedjwt", + AuthID: "access-policy:this-uid", + ClientParams: authn.ClientParams{ + FetchSyncedUser: true, + SyncPermissions: true, + }, }, - wantErr: errJWTMismatchedNamespaceClaims.Errorf("id token namespace: %s, access token namespace: %s", mismatchingNamespaceIDPayload.Rest.Namespace, validPayload.Rest.Namespace), }, { - name: "fail authentication as user when id token namespace claim doesn't match allowed namespace", - payload: &validPayloadWildcardNamespace, - idPayload: &validIDPayload, - orgID: 1, - initTestEnv: func(env *testEnv) { - env.userSvc.ExpectedSignedInUser = &user.SignedInUser{ - UserID: 2, - OrgID: 1, - OrgRole: roletype.RoleAdmin, - Name: "John Doe", - Email: "johndoe@grafana.com", - Login: "johndoe", - } - }, - wantErr: errJWTDisallowedNamespaceClaim, + name: "should return error when id token namespace is a wildcard", + accessToken: &validAccessTokenClaims, + idToken: &invalidWildcardNamespaceIDTokenClaims, + orgID: 1, + wantErr: errExtJWTDisallowedNamespaceClaim, }, + { + name: "should return error when id token has wildcard namespace", + accessToken: &validAccessTokenClaims, + idToken: &invalidNamespaceIDTokenClaims, + orgID: 1, + wantErr: errExtJWTDisallowedNamespaceClaim, + }, + + { + name: "should return error when id token subject is not tied to a user", + accessToken: &validAccessTokenClaims, + idToken: &invalidSubjectIDTokenClaims, + orgID: 1, + wantErr: errExtJWTInvalidSubject, + }, + { name: "should return error when the subject is not an access-policy", - payload: &JWTAccessTokenClaims{ + accessToken: &accessTokenClaims{ Claims: &jwt.Claims{ Issuer: "http://localhost:3000", Subject: "user:2", @@ -294,34 +307,31 @@ func TestExtendedJWT_Authenticate(t *testing.T) { Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)), }, - Rest: authlib.AccessTokenClaims{ + Rest: authnlib.AccessTokenClaims{ Permissions: []string{"fixed:folders:reader"}, + Namespace: "default", }, }, orgID: 1, - want: nil, - wantErr: errJWTInvalid.Errorf("Failed to parse sub: %s", "invalid subject format"), + wantErr: errExtJWTInvalidSubject, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { env := setupTestCtx(nil) - if tc.initTestEnv != nil { - tc.initTestEnv(env) - } validHTTPReq := &http.Request{ Header: map[string][]string{ - "X-Access-Token": {generateToken(*tc.payload, pk, jose.RS256)}, + "X-Access-Token": {generateToken(*tc.accessToken, pk, jose.RS256)}, }, } - env.s.accessTokenVerifier = &mockVerifier{Claims: *tc.payload} - if tc.idPayload != nil { - env.s.accessTokenVerifier = &mockVerifier{Claims: *tc.payload} - env.s.idTokenVerifier = &mockIDVerifier{Claims: *tc.idPayload} - validHTTPReq.Header.Add(extJWTAuthorizationHeaderName, generateIDToken(*tc.idPayload, pk, jose.RS256)) + env.s.accessTokenVerifier = &mockVerifier{Claims: *tc.accessToken} + if tc.idToken != nil { + env.s.accessTokenVerifier = &mockVerifier{Claims: *tc.accessToken} + env.s.idTokenVerifier = &mockIDVerifier{Claims: *tc.idToken} + validHTTPReq.Header.Add(extJWTAuthorizationHeaderName, generateIDToken(*tc.idToken, pk, jose.RS256)) } id, err := env.s.Authenticate(context.Background(), &authn.Request{ @@ -330,7 +340,8 @@ func TestExtendedJWT_Authenticate(t *testing.T) { Resp: nil, }) if tc.wantErr != nil { - require.ErrorIs(t, err, tc.wantErr) + assert.ErrorIs(t, err, tc.wantErr) + assert.Nil(t, id) } else { require.NoError(t, err) assert.EqualValues(t, tc.want, id, fmt.Sprintf("%+v", id)) @@ -343,8 +354,8 @@ func TestExtendedJWT_Authenticate(t *testing.T) { func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) { type testCase struct { name string - payload *JWTAccessTokenClaims - idPayload *JWTIDTokenClaims + payload *accessTokenClaims + idPayload *idTokenClaims alg jose.SignatureAlgorithm generateWrongTyp bool } @@ -352,7 +363,7 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) { testCases := []testCase{ { name: "missing iss", - payload: &JWTAccessTokenClaims{ + payload: &accessTokenClaims{ Claims: &jwt.Claims{ Subject: "access-policy:this-uid", Audience: jwt.Audience{"http://localhost:3000"}, @@ -360,14 +371,14 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) { Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)), }, - Rest: authlib.AccessTokenClaims{ + Rest: authnlib.AccessTokenClaims{ Scopes: []string{"profile", "groups"}, }, }, }, { name: "missing expiry", - payload: &JWTAccessTokenClaims{ + payload: &accessTokenClaims{ Claims: &jwt.Claims{ Issuer: "http://localhost:3000", Subject: "access-policy:this-uid", @@ -375,14 +386,14 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) { ID: "1234567890", IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)), }, - Rest: authlib.AccessTokenClaims{ + Rest: authnlib.AccessTokenClaims{ Scopes: []string{"profile", "groups"}, }, }, }, { name: "expired token", - payload: &JWTAccessTokenClaims{ + payload: &accessTokenClaims{ Claims: &jwt.Claims{ Issuer: "http://localhost:3000", Subject: "access-policy:this-uid", @@ -391,14 +402,14 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) { Expiry: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)), }, - Rest: authlib.AccessTokenClaims{ + Rest: authnlib.AccessTokenClaims{ Scopes: []string{"profile", "groups"}, }, }, }, { name: "missing aud", - payload: &JWTAccessTokenClaims{ + payload: &accessTokenClaims{ Claims: &jwt.Claims{ Issuer: "http://localhost:3000", Subject: "access-policy:this-uid", @@ -406,14 +417,14 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) { Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)), }, - Rest: authlib.AccessTokenClaims{ + Rest: authnlib.AccessTokenClaims{ Scopes: []string{"profile", "groups"}, }, }, }, { name: "wrong aud", - payload: &JWTAccessTokenClaims{ + payload: &accessTokenClaims{ Claims: &jwt.Claims{ Issuer: "http://localhost:3000", Subject: "access-policy:this-uid", @@ -422,19 +433,19 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) { Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)), }, - Rest: authlib.AccessTokenClaims{ + Rest: authnlib.AccessTokenClaims{ Scopes: []string{"profile", "groups"}, }, }, }, { name: "wrong typ", - idPayload: &validIDPayload, + idPayload: &validIDTokenClaims, generateWrongTyp: true, }, { name: "missing sub", - payload: &JWTAccessTokenClaims{ + payload: &accessTokenClaims{ Claims: &jwt.Claims{ Issuer: "http://localhost:3000", Audience: jwt.Audience{"http://localhost:3000"}, @@ -442,14 +453,14 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) { Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)), }, - Rest: authlib.AccessTokenClaims{ + Rest: authnlib.AccessTokenClaims{ Scopes: []string{"profile", "groups"}, }, }, }, { name: "missing iat", - payload: &JWTAccessTokenClaims{ + payload: &accessTokenClaims{ Claims: &jwt.Claims{ Issuer: "http://localhost:3000", Subject: "access-policy:this-uid", @@ -457,14 +468,14 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) { ID: "1234567890", Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), }, - Rest: authlib.AccessTokenClaims{ + Rest: authnlib.AccessTokenClaims{ Scopes: []string{"profile", "groups"}, }, }, }, { name: "iat later than current time", - payload: &JWTAccessTokenClaims{ + payload: &accessTokenClaims{ Claims: &jwt.Claims{ Issuer: "http://localhost:3000", Subject: "access-policy:this-uid", @@ -473,14 +484,14 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) { Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 2, 0, 0, time.UTC)), }, - Rest: authlib.AccessTokenClaims{ + Rest: authnlib.AccessTokenClaims{ Scopes: []string{"profile", "groups"}, }, }, }, { name: "unsupported alg", - payload: &JWTAccessTokenClaims{ + payload: &accessTokenClaims{ Claims: &jwt.Claims{ Issuer: "http://localhost:3000", Subject: "access-policy:this-uid", @@ -489,7 +500,7 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) { Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)), }, - Rest: authlib.AccessTokenClaims{ + Rest: authnlib.AccessTokenClaims{ Scopes: []string{"profile", "groups"}, }, }, @@ -528,30 +539,21 @@ func setupTestCtx(cfg *setting.Cfg) *testEnv { } } - signingKeysSvc := &signingkeystest.FakeSigningKeysService{ - ExpectedSinger: pk, - ExpectedKeyID: signingkeys.ServerPrivateKeyID, - } - - userSvc := &usertest.FakeUserService{} - - extJwtClient := ProvideExtendedJWT(userSvc, cfg, signingKeysSvc) + extJwtClient := ProvideExtendedJWT(cfg) return &testEnv{ - userSvc: userSvc, - s: extJwtClient, + s: extJwtClient, } } type testEnv struct { - userSvc *usertest.FakeUserService - s *ExtendedJWT + s *ExtendedJWT } -func generateToken(payload JWTAccessTokenClaims, signingKey any, alg jose.SignatureAlgorithm) string { +func generateToken(payload accessTokenClaims, signingKey any, alg jose.SignatureAlgorithm) string { signer, _ := jose.NewSigner(jose.SigningKey{Algorithm: alg, Key: signingKey}, &jose.SignerOptions{ ExtraHeaders: map[jose.HeaderKey]any{ - jose.HeaderType: authlib.TokenTypeAccess, + jose.HeaderType: authnlib.TokenTypeAccess, "kid": "default", }}) @@ -559,10 +561,10 @@ func generateToken(payload JWTAccessTokenClaims, signingKey any, alg jose.Signat return result } -func generateIDToken(payload JWTIDTokenClaims, signingKey any, alg jose.SignatureAlgorithm) string { +func generateIDToken(payload idTokenClaims, signingKey any, alg jose.SignatureAlgorithm) string { signer, _ := jose.NewSigner(jose.SigningKey{Algorithm: alg, Key: signingKey}, &jose.SignerOptions{ ExtraHeaders: map[jose.HeaderKey]any{ - jose.HeaderType: authlib.TokenTypeID, + jose.HeaderType: authnlib.TokenTypeID, "kid": "default", }}) diff --git a/pkg/services/authn/clients/jwt.go b/pkg/services/authn/clients/jwt.go index a840d1c07b8..3e49d31d379 100644 --- a/pkg/services/authn/clients/jwt.go +++ b/pkg/services/authn/clients/jwt.go @@ -27,10 +27,6 @@ var ( "jwt.missing_claim", errutil.WithPublicMessage("Missing mandatory claim in JWT")) errJWTInvalidRole = errutil.Forbidden( "jwt.invalid_role", errutil.WithPublicMessage("Invalid Role in claim")) - errJWTMismatchedNamespaceClaims = errutil.Unauthorized( - "jwt.namespace_mismatch", errutil.WithPublicMessage("Namespace claims didn't match between id token and access token")) - errJWTDisallowedNamespaceClaim = errutil.Unauthorized( - "jwt.namespace_mismatch", errutil.WithPublicMessage("Namespace claim doesn't allow access to requested namespace")) ) func ProvideJWT(jwtService auth.JWTVerifierService, cfg *setting.Cfg) *JWT {