Authn: Support access token wildcard namespace (#87816)

* Authn+ExtJWT: allow wildcard namespace for access tokens and restructure validation
This commit is contained in:
Karl Persson
2024-05-16 10:47:20 +02:00
committed by GitHub
parent 4867fd3069
commit 5c27f223af
4 changed files with 198 additions and 192 deletions

View File

@ -18,7 +18,6 @@ import (
"github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/quota"
"github.com/grafana/grafana/pkg/services/rendering" "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/services/user"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
) )
@ -34,7 +33,7 @@ func ProvideRegistration(
loginAttempts loginattempt.Service, quotaService quota.Service, loginAttempts loginattempt.Service, quotaService quota.Service,
authInfoService login.AuthInfoService, renderService rendering.Service, authInfoService login.AuthInfoService, renderService rendering.Service,
features *featuremgmt.FeatureManager, oauthTokenService oauthtoken.OAuthTokenService, 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, ldapService service.LDAP, settingsProviderService setting.Provider,
) Registration { ) Registration {
logger := log.New("authn.registration") logger := log.New("authn.registration")
@ -86,7 +85,7 @@ func ProvideRegistration(
} }
if cfg.ExtJWTAuth.Enabled && features.IsEnabledGlobally(featuremgmt.FlagAuthAPIAccessTokenAuth) { 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() { for name := range socialService.GetOAuthProviders() {

View File

@ -4,7 +4,6 @@ import (
"context" "context"
"fmt" "fmt"
"net/http" "net/http"
"strconv"
"strings" "strings"
"github.com/go-jose/go-jose/v3/jwt" "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/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/login" "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/setting"
"github.com/grafana/grafana/pkg/util/errutil"
) )
var _ authn.Client = new(ExtendedJWT) var _ authn.Client = new(ExtendedJWT)
@ -26,9 +24,23 @@ const (
extJWTAccessTokenExpectAudience = "grafana" extJWTAccessTokenExpectAudience = "grafana"
) )
func ProvideExtendedJWT(userService user.Service, cfg *setting.Cfg, var (
signingKeys signingkeys.Service) *ExtendedJWT { errExtJWTInvalid = errutil.Unauthorized(
verifier := authlib.NewAccessTokenVerifier(authlib.VerifierConfig{ "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, SigningKeysURL: cfg.ExtJWTAuth.JWKSUrl,
AllowedAudiences: []string{extJWTAccessTokenExpectAudience}, AllowedAudiences: []string{extJWTAccessTokenExpectAudience},
}) })
@ -42,11 +54,8 @@ func ProvideExtendedJWT(userService user.Service, cfg *setting.Cfg,
return &ExtendedJWT{ return &ExtendedJWT{
cfg: cfg, cfg: cfg,
log: log.New(authn.ClientExtendedJWT), log: log.New(authn.ClientExtendedJWT),
userService: userService,
signingKeys: signingKeys,
accessTokenVerifier: verifier,
namespaceMapper: request.GetNamespaceMapper(cfg), namespaceMapper: request.GetNamespaceMapper(cfg),
accessTokenVerifier: accessTokenVerifier,
idTokenVerifier: idTokenVerifier, idTokenVerifier: idTokenVerifier,
} }
} }
@ -54,8 +63,6 @@ func ProvideExtendedJWT(userService user.Service, cfg *setting.Cfg,
type ExtendedJWT struct { type ExtendedJWT struct {
cfg *setting.Cfg cfg *setting.Cfg
log log.Logger log log.Logger
userService user.Service
signingKeys signingkeys.Service
accessTokenVerifier authlib.Verifier[authlib.AccessTokenClaims] accessTokenVerifier authlib.Verifier[authlib.AccessTokenClaims]
idTokenVerifier authlib.Verifier[authlib.IDTokenClaims] idTokenVerifier authlib.Verifier[authlib.IDTokenClaims]
namespaceMapper request.NamespaceMapper 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) claims, err := s.accessTokenVerifier.Verify(ctx, jwtToken)
if err != nil { if err != nil {
s.log.Error("Failed to verify access token", "error", err) 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) 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) idTokenClaims, err := s.idTokenVerifier.Verify(ctx, idToken)
if err != nil { if err != nil {
s.log.Error("Failed to verify id token", "error", err) 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) return s.authenticateAsUser(idTokenClaims, claims)
@ -88,40 +95,43 @@ func (s *ExtendedJWT) IsEnabled() bool {
return s.cfg.ExtJWTAuth.Enabled return s.cfg.ExtJWTAuth.Enabled
} }
func (s *ExtendedJWT) authenticateAsUser(idTokenClaims *authlib.Claims[authlib.IDTokenClaims], func (s *ExtendedJWT) authenticateAsUser(
accessTokenClaims *authlib.Claims[authlib.AccessTokenClaims]) (*authn.Identity, error) { idTokenClaims *authlib.Claims[authlib.IDTokenClaims],
// compare the incoming namespace claim against what namespaceMapper returns 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 { if allowedNamespace := s.namespaceMapper(s.getDefaultOrgID()); idTokenClaims.Rest.Namespace != allowedNamespace {
return nil, errJWTDisallowedNamespaceClaim return nil, errExtJWTDisallowedNamespaceClaim.Errorf("unexpected id token namespace: %s", idTokenClaims.Rest.Namespace)
}
// 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)
} }
// Only allow access policies to impersonate // Allow access tokens with either the same namespace as the validated id token namespace or wildcard (`*`).
if !strings.HasPrefix(accessTokenClaims.Subject, fmt.Sprintf("%s:", authn.NamespaceAccessPolicy)) { if !accessTokenClaims.Rest.NamespaceMatches(idTokenClaims.Rest.Namespace) {
s.log.Error("Invalid subject", "subject", accessTokenClaims.Subject) return nil, errExtJWTMisMatchedNamespaceClaims.Errorf("unexpected access token namespace: %s", accessTokenClaims.Rest.Namespace)
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)
} }
id, err := authn.ParseNamespaceID(idTokenClaims.Subject) accessID, err := authn.ParseNamespaceID(accessTokenClaims.Subject)
if err != nil { 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{ return &authn.Identity{
ID: id, ID: userID,
OrgID: s.getDefaultOrgID(), OrgID: s.getDefaultOrgID(),
AuthenticatedBy: login.ExtendedJWTModule, AuthenticatedBy: login.ExtendedJWTModule,
AuthID: accessTokenClaims.Subject, AuthID: accessID.String(),
ClientParams: authn.ClientParams{ ClientParams: authn.ClientParams{
SyncPermissions: true, SyncPermissions: true,
FetchPermissionsParams: authn.FetchPermissionsParams{ 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) { func (s *ExtendedJWT) authenticateAsService(claims *authlib.Claims[authlib.AccessTokenClaims]) (*authn.Identity, error) {
if !strings.HasPrefix(claims.Subject, fmt.Sprintf("%s:", authn.NamespaceAccessPolicy)) { // Allow access tokens with that has a wildcard namespace or a namespace matching this instance.
s.log.Error("Invalid subject", "subject", claims.Subject) if allowedNamespace := s.namespaceMapper(s.getDefaultOrgID()); !claims.Rest.NamespaceMatches(allowedNamespace) {
return nil, errJWTInvalid.Errorf("Failed to parse sub: %s", "invalid subject format") return nil, errExtJWTDisallowedNamespaceClaim.Errorf("unexpected access token namespace: %s", claims.Rest.Namespace)
}
// 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
} }
id, err := authn.ParseNamespaceID(claims.Subject) id, err := authn.ParseNamespaceID(claims.Subject)
if err != nil { 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{ return &authn.Identity{

View File

@ -15,102 +15,108 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "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/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" "github.com/grafana/grafana/pkg/setting"
) )
type ( type (
JWTAccessTokenClaims = authlib.Claims[authlib.AccessTokenClaims] idTokenClaims = authnlib.Claims[authnlib.IDTokenClaims]
JWTIDTokenClaims = authlib.Claims[authlib.IDTokenClaims] accessTokenClaims = authnlib.Claims[authnlib.AccessTokenClaims]
) )
var ( var (
validPayload = JWTAccessTokenClaims{ validAccessTokenClaims = accessTokenClaims{
Claims: &jwt.Claims{ Claims: &jwt.Claims{
Issuer: "http://localhost:3000",
Subject: "access-policy:this-uid", 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)), 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)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
}, },
Rest: authlib.AccessTokenClaims{ Rest: authnlib.AccessTokenClaims{
Scopes: []string{"profile", "groups"}, Scopes: []string{"profile", "groups"},
DelegatedPermissions: []string{"dashboards:create", "folders:read", "datasources:explore", "datasources.insights:read"}, DelegatedPermissions: []string{"dashboards:create", "folders:read", "datasources:explore", "datasources.insights:read"},
Permissions: []string{"fixed:folders:reader"}, Permissions: []string{"fixed:folders:reader"},
Namespace: "default", // org ID of 1 is special and translates to default Namespace: "default", // org ID of 1 is special and translates to default
}, },
} }
validIDPayload = JWTIDTokenClaims{ validIDTokenClaims = idTokenClaims{
Claims: &jwt.Claims{ Claims: &jwt.Claims{
Issuer: "http://localhost:3000",
Subject: "user:2", Subject: "user:2",
Audience: jwt.Audience{"stack:1"},
ID: "1234567890",
Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), 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)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
}, },
Rest: authlib.IDTokenClaims{ Rest: authnlib.IDTokenClaims{
AuthenticatedBy: "extended_jwt", AuthenticatedBy: "extended_jwt",
Namespace: "default", // org ID of 1 is special and translates to default Namespace: "default", // org ID of 1 is special and translates to default
}, },
} }
validPayloadWildcardNamespace = JWTAccessTokenClaims{ validAcessTokenClaimsWildcard = accessTokenClaims{
Claims: &jwt.Claims{ Claims: &jwt.Claims{
Issuer: "http://localhost:3000",
Subject: "access-policy:this-uid", 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)), 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)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
}, },
Rest: authlib.AccessTokenClaims{ Rest: authnlib.AccessTokenClaims{
Namespace: "*", Namespace: "*",
}, },
} }
mismatchingNamespaceIDPayload = JWTIDTokenClaims{ invalidWildcardNamespaceIDTokenClaims = idTokenClaims{
Claims: &jwt.Claims{ Claims: &jwt.Claims{
Issuer: "http://localhost:3000",
Subject: "user:2", Subject: "user:2",
Audience: jwt.Audience{"stack:1234"},
ID: "1234567890",
Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), 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)), 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", AuthenticatedBy: "extended_jwt",
Namespace: "org-2", 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) pk, _ = rsa.GenerateKey(rand.Reader, 4096)
) )
var _ authlib.Verifier[authlib.IDTokenClaims] = &mockIDVerifier{} var _ authnlib.Verifier[authnlib.IDTokenClaims] = &mockIDVerifier{}
type mockIDVerifier struct { type mockIDVerifier struct {
Claims JWTIDTokenClaims Claims idTokenClaims
Error error 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 return &m.Claims, m.Error
} }
var _ authlib.Verifier[authlib.AccessTokenClaims] = &mockVerifier{} var _ authnlib.Verifier[authnlib.AccessTokenClaims] = &mockVerifier{}
type mockVerifier struct { type mockVerifier struct {
Claims JWTAccessTokenClaims Claims accessTokenClaims
Error error 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 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", name: "should return true when Authorization header contains Bearer prefix",
cfg: nil, cfg: nil,
authHeaderFunc: func() string { return "Bearer " + generateToken(validPayload, pk, jose.RS256) }, authHeaderFunc: func() string { return "Bearer " + generateToken(validAccessTokenClaims, pk, jose.RS256) },
want: true, want: true,
}, },
{ {
name: "should return true when Authorization header only contains the token", name: "should return true when Authorization header only contains the token",
cfg: nil, cfg: nil,
authHeaderFunc: func() string { return generateToken(validPayload, pk, jose.RS256) }, authHeaderFunc: func() string { return generateToken(validAccessTokenClaims, pk, jose.RS256) },
want: true, want: true,
}, },
{ {
@ -165,7 +171,7 @@ func TestExtendedJWT_Test(t *testing.T) {
}, },
}, },
authHeaderFunc: func() string { authHeaderFunc: func() string {
payload := validPayload payload := validAccessTokenClaims
payload.Issuer = "http://unknown-issuer" payload.Issuer = "http://unknown-issuer"
return generateToken(payload, pk, jose.RS256) return generateToken(payload, pk, jose.RS256)
}, },
@ -195,17 +201,16 @@ func TestExtendedJWT_Test(t *testing.T) {
func TestExtendedJWT_Authenticate(t *testing.T) { func TestExtendedJWT_Authenticate(t *testing.T) {
type testCase struct { type testCase struct {
name string name string
payload *JWTAccessTokenClaims accessToken *accessTokenClaims
idPayload *JWTIDTokenClaims idToken *idTokenClaims
orgID int64 orgID int64
want *authn.Identity want *authn.Identity
initTestEnv func(env *testEnv)
wantErr error wantErr error
} }
testCases := []testCase{ testCases := []testCase{
{ {
name: "successful authentication as service", name: "should authenticate as service",
payload: &validPayload, accessToken: &validAccessTokenClaims,
orgID: 1, orgID: 1,
want: &authn.Identity{ want: &authn.Identity{
ID: authn.MustParseNamespaceID("access-policy:this-uid"), ID: authn.MustParseNamespaceID("access-policy:this-uid"),
@ -217,23 +222,27 @@ func TestExtendedJWT_Authenticate(t *testing.T) {
SyncPermissions: true, SyncPermissions: true,
FetchPermissionsParams: authn.FetchPermissionsParams{Roles: []string{"fixed:folders:reader"}}}, FetchPermissionsParams: authn.FetchPermissionsParams{Roles: []string{"fixed:folders:reader"}}},
}, },
wantErr: nil,
}, },
{ {
name: "successful authentication as user", name: "should authenticate as service using wildcard namespace",
payload: &validPayload, accessToken: &validAcessTokenClaimsWildcard,
idPayload: &validIDPayload,
orgID: 1, orgID: 1,
initTestEnv: func(env *testEnv) { want: &authn.Identity{
env.userSvc.ExpectedSignedInUser = &user.SignedInUser{ ID: authn.MustParseNamespaceID("access-policy:this-uid"),
UserID: 2, UID: authn.MustParseNamespaceID("access-policy:this-uid"),
OrgID: 1, OrgID: 1,
OrgRole: roletype.RoleAdmin, AuthenticatedBy: "extendedjwt",
Name: "John Doe", AuthID: "access-policy:this-uid",
Email: "johndoe@grafana.com", ClientParams: authn.ClientParams{
Login: "johndoe", SyncPermissions: true,
}
}, },
},
},
{
name: "should authenticate as user",
accessToken: &validAccessTokenClaims,
idToken: &validIDTokenClaims,
orgID: 1,
want: &authn.Identity{ want: &authn.Identity{
ID: authn.MustParseNamespaceID("user:2"), ID: authn.MustParseNamespaceID("user:2"),
OrgID: 1, 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", name: "should authenticate as user using wildcard namespace for access token",
payload: &validPayload, accessToken: &validAcessTokenClaimsWildcard,
idPayload: &mismatchingNamespaceIDPayload, idToken: &validIDTokenClaims,
orgID: 1, orgID: 1,
initTestEnv: func(env *testEnv) { want: &authn.Identity{
env.userSvc.ExpectedSignedInUser = &user.SignedInUser{ ID: authn.MustParseNamespaceID("user:2"),
UserID: 2,
OrgID: 1, OrgID: 1,
OrgRole: roletype.RoleAdmin, AuthenticatedBy: "extendedjwt",
Name: "John Doe", AuthID: "access-policy:this-uid",
Email: "johndoe@grafana.com", ClientParams: authn.ClientParams{
Login: "johndoe", 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", name: "should return error when id token namespace is a wildcard",
payload: &validPayloadWildcardNamespace, accessToken: &validAccessTokenClaims,
idPayload: &validIDPayload, idToken: &invalidWildcardNamespaceIDTokenClaims,
orgID: 1, orgID: 1,
initTestEnv: func(env *testEnv) { wantErr: errExtJWTDisallowedNamespaceClaim,
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 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", name: "should return error when the subject is not an access-policy",
payload: &JWTAccessTokenClaims{ accessToken: &accessTokenClaims{
Claims: &jwt.Claims{ Claims: &jwt.Claims{
Issuer: "http://localhost:3000", Issuer: "http://localhost:3000",
Subject: "user:2", 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)), 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)), 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"}, Permissions: []string{"fixed:folders:reader"},
Namespace: "default",
}, },
}, },
orgID: 1, orgID: 1,
want: nil, wantErr: errExtJWTInvalidSubject,
wantErr: errJWTInvalid.Errorf("Failed to parse sub: %s", "invalid subject format"),
}, },
} }
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
env := setupTestCtx(nil) env := setupTestCtx(nil)
if tc.initTestEnv != nil {
tc.initTestEnv(env)
}
validHTTPReq := &http.Request{ validHTTPReq := &http.Request{
Header: map[string][]string{ 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} env.s.accessTokenVerifier = &mockVerifier{Claims: *tc.accessToken}
if tc.idPayload != nil { if tc.idToken != nil {
env.s.accessTokenVerifier = &mockVerifier{Claims: *tc.payload} env.s.accessTokenVerifier = &mockVerifier{Claims: *tc.accessToken}
env.s.idTokenVerifier = &mockIDVerifier{Claims: *tc.idPayload} env.s.idTokenVerifier = &mockIDVerifier{Claims: *tc.idToken}
validHTTPReq.Header.Add(extJWTAuthorizationHeaderName, generateIDToken(*tc.idPayload, pk, jose.RS256)) validHTTPReq.Header.Add(extJWTAuthorizationHeaderName, generateIDToken(*tc.idToken, pk, jose.RS256))
} }
id, err := env.s.Authenticate(context.Background(), &authn.Request{ id, err := env.s.Authenticate(context.Background(), &authn.Request{
@ -330,7 +340,8 @@ func TestExtendedJWT_Authenticate(t *testing.T) {
Resp: nil, Resp: nil,
}) })
if tc.wantErr != nil { if tc.wantErr != nil {
require.ErrorIs(t, err, tc.wantErr) assert.ErrorIs(t, err, tc.wantErr)
assert.Nil(t, id)
} else { } else {
require.NoError(t, err) require.NoError(t, err)
assert.EqualValues(t, tc.want, id, fmt.Sprintf("%+v", id)) 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) { func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) {
type testCase struct { type testCase struct {
name string name string
payload *JWTAccessTokenClaims payload *accessTokenClaims
idPayload *JWTIDTokenClaims idPayload *idTokenClaims
alg jose.SignatureAlgorithm alg jose.SignatureAlgorithm
generateWrongTyp bool generateWrongTyp bool
} }
@ -352,7 +363,7 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) {
testCases := []testCase{ testCases := []testCase{
{ {
name: "missing iss", name: "missing iss",
payload: &JWTAccessTokenClaims{ payload: &accessTokenClaims{
Claims: &jwt.Claims{ Claims: &jwt.Claims{
Subject: "access-policy:this-uid", Subject: "access-policy:this-uid",
Audience: jwt.Audience{"http://localhost:3000"}, 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)), 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)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
}, },
Rest: authlib.AccessTokenClaims{ Rest: authnlib.AccessTokenClaims{
Scopes: []string{"profile", "groups"}, Scopes: []string{"profile", "groups"},
}, },
}, },
}, },
{ {
name: "missing expiry", name: "missing expiry",
payload: &JWTAccessTokenClaims{ payload: &accessTokenClaims{
Claims: &jwt.Claims{ Claims: &jwt.Claims{
Issuer: "http://localhost:3000", Issuer: "http://localhost:3000",
Subject: "access-policy:this-uid", Subject: "access-policy:this-uid",
@ -375,14 +386,14 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) {
ID: "1234567890", ID: "1234567890",
IssuedAt: 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"}, Scopes: []string{"profile", "groups"},
}, },
}, },
}, },
{ {
name: "expired token", name: "expired token",
payload: &JWTAccessTokenClaims{ payload: &accessTokenClaims{
Claims: &jwt.Claims{ Claims: &jwt.Claims{
Issuer: "http://localhost:3000", Issuer: "http://localhost:3000",
Subject: "access-policy:this-uid", 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)), 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)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
}, },
Rest: authlib.AccessTokenClaims{ Rest: authnlib.AccessTokenClaims{
Scopes: []string{"profile", "groups"}, Scopes: []string{"profile", "groups"},
}, },
}, },
}, },
{ {
name: "missing aud", name: "missing aud",
payload: &JWTAccessTokenClaims{ payload: &accessTokenClaims{
Claims: &jwt.Claims{ Claims: &jwt.Claims{
Issuer: "http://localhost:3000", Issuer: "http://localhost:3000",
Subject: "access-policy:this-uid", 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)), 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)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
}, },
Rest: authlib.AccessTokenClaims{ Rest: authnlib.AccessTokenClaims{
Scopes: []string{"profile", "groups"}, Scopes: []string{"profile", "groups"},
}, },
}, },
}, },
{ {
name: "wrong aud", name: "wrong aud",
payload: &JWTAccessTokenClaims{ payload: &accessTokenClaims{
Claims: &jwt.Claims{ Claims: &jwt.Claims{
Issuer: "http://localhost:3000", Issuer: "http://localhost:3000",
Subject: "access-policy:this-uid", 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)), 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)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
}, },
Rest: authlib.AccessTokenClaims{ Rest: authnlib.AccessTokenClaims{
Scopes: []string{"profile", "groups"}, Scopes: []string{"profile", "groups"},
}, },
}, },
}, },
{ {
name: "wrong typ", name: "wrong typ",
idPayload: &validIDPayload, idPayload: &validIDTokenClaims,
generateWrongTyp: true, generateWrongTyp: true,
}, },
{ {
name: "missing sub", name: "missing sub",
payload: &JWTAccessTokenClaims{ payload: &accessTokenClaims{
Claims: &jwt.Claims{ Claims: &jwt.Claims{
Issuer: "http://localhost:3000", Issuer: "http://localhost:3000",
Audience: jwt.Audience{"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)), 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)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
}, },
Rest: authlib.AccessTokenClaims{ Rest: authnlib.AccessTokenClaims{
Scopes: []string{"profile", "groups"}, Scopes: []string{"profile", "groups"},
}, },
}, },
}, },
{ {
name: "missing iat", name: "missing iat",
payload: &JWTAccessTokenClaims{ payload: &accessTokenClaims{
Claims: &jwt.Claims{ Claims: &jwt.Claims{
Issuer: "http://localhost:3000", Issuer: "http://localhost:3000",
Subject: "access-policy:this-uid", Subject: "access-policy:this-uid",
@ -457,14 +468,14 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) {
ID: "1234567890", ID: "1234567890",
Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)),
}, },
Rest: authlib.AccessTokenClaims{ Rest: authnlib.AccessTokenClaims{
Scopes: []string{"profile", "groups"}, Scopes: []string{"profile", "groups"},
}, },
}, },
}, },
{ {
name: "iat later than current time", name: "iat later than current time",
payload: &JWTAccessTokenClaims{ payload: &accessTokenClaims{
Claims: &jwt.Claims{ Claims: &jwt.Claims{
Issuer: "http://localhost:3000", Issuer: "http://localhost:3000",
Subject: "access-policy:this-uid", 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)), 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)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 2, 0, 0, time.UTC)),
}, },
Rest: authlib.AccessTokenClaims{ Rest: authnlib.AccessTokenClaims{
Scopes: []string{"profile", "groups"}, Scopes: []string{"profile", "groups"},
}, },
}, },
}, },
{ {
name: "unsupported alg", name: "unsupported alg",
payload: &JWTAccessTokenClaims{ payload: &accessTokenClaims{
Claims: &jwt.Claims{ Claims: &jwt.Claims{
Issuer: "http://localhost:3000", Issuer: "http://localhost:3000",
Subject: "access-policy:this-uid", 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)), 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)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
}, },
Rest: authlib.AccessTokenClaims{ Rest: authnlib.AccessTokenClaims{
Scopes: []string{"profile", "groups"}, Scopes: []string{"profile", "groups"},
}, },
}, },
@ -528,30 +539,21 @@ func setupTestCtx(cfg *setting.Cfg) *testEnv {
} }
} }
signingKeysSvc := &signingkeystest.FakeSigningKeysService{ extJwtClient := ProvideExtendedJWT(cfg)
ExpectedSinger: pk,
ExpectedKeyID: signingkeys.ServerPrivateKeyID,
}
userSvc := &usertest.FakeUserService{}
extJwtClient := ProvideExtendedJWT(userSvc, cfg, signingKeysSvc)
return &testEnv{ return &testEnv{
userSvc: userSvc,
s: extJwtClient, s: extJwtClient,
} }
} }
type testEnv struct { 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{ signer, _ := jose.NewSigner(jose.SigningKey{Algorithm: alg, Key: signingKey}, &jose.SignerOptions{
ExtraHeaders: map[jose.HeaderKey]any{ ExtraHeaders: map[jose.HeaderKey]any{
jose.HeaderType: authlib.TokenTypeAccess, jose.HeaderType: authnlib.TokenTypeAccess,
"kid": "default", "kid": "default",
}}) }})
@ -559,10 +561,10 @@ func generateToken(payload JWTAccessTokenClaims, signingKey any, alg jose.Signat
return result 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{ signer, _ := jose.NewSigner(jose.SigningKey{Algorithm: alg, Key: signingKey}, &jose.SignerOptions{
ExtraHeaders: map[jose.HeaderKey]any{ ExtraHeaders: map[jose.HeaderKey]any{
jose.HeaderType: authlib.TokenTypeID, jose.HeaderType: authnlib.TokenTypeID,
"kid": "default", "kid": "default",
}}) }})

View File

@ -27,10 +27,6 @@ var (
"jwt.missing_claim", errutil.WithPublicMessage("Missing mandatory claim in JWT")) "jwt.missing_claim", errutil.WithPublicMessage("Missing mandatory claim in JWT"))
errJWTInvalidRole = errutil.Forbidden( errJWTInvalidRole = errutil.Forbidden(
"jwt.invalid_role", errutil.WithPublicMessage("Invalid Role in claim")) "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 { func ProvideJWT(jwtService auth.JWTVerifierService, cfg *setting.Cfg) *JWT {