diff --git a/conf/defaults.ini b/conf/defaults.ini index 207401eda1a..0eb48915b3c 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -564,6 +564,7 @@ jwk_set_file = cache_ttl = 60m expected_claims = {} key_file = +auto_sign_up = false #################################### Auth LDAP ########################### [auth.ldap] diff --git a/conf/sample.ini b/conf/sample.ini index 87ded5d82e2..f271f95f059 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -548,6 +548,7 @@ ;cache_ttl = 60m ;expected_claims = {"aud": ["foo", "bar"]} ;key_file = /path/to/key/file +;auto_sign_up = false #################################### Auth LDAP ########################## [auth.ldap] diff --git a/docs/sources/auth/jwt.md b/docs/sources/auth/jwt.md index c484baac9fa..aa1cda58c3e 100644 --- a/docs/sources/auth/jwt.md +++ b/docs/sources/auth/jwt.md @@ -44,8 +44,13 @@ username_claim = sub # Specify a claim to use as an email to sign in. email_claim = sub + +# auto-create users if they are not already matched +# auto_sign_up = true ``` +If `auto_sign_up` is enabled, then the `sub` claim is used as the "external Auth ID". The `name` claim is used as the user's full name if it is present. + ## Signature verification JSON web token integrity needs to be verified so cryptographic signature is used for this purpose. So we expect that every token must be signed with some known cryptographic key. diff --git a/pkg/middleware/middleware_jwt_auth_test.go b/pkg/middleware/middleware_jwt_auth_test.go index b6f73f3ceeb..770d7b1c802 100644 --- a/pkg/middleware/middleware_jwt_auth_test.go +++ b/pkg/middleware/middleware_jwt_auth_test.go @@ -5,11 +5,12 @@ import ( "errors" "testing" + "github.com/stretchr/testify/assert" + "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/contexthandler" "github.com/grafana/grafana/pkg/setting" - "github.com/stretchr/testify/assert" ) func TestMiddlewareJWTAuth(t *testing.T) { @@ -29,6 +30,10 @@ func TestMiddlewareJWTAuth(t *testing.T) { cfg.JWTAuthEmailClaim = "foo-email" } + configureAutoSignUp := func(cfg *setting.Cfg) { + cfg.JWTAuthAutoSignUp = true + } + token := "some-token" middlewareScenario(t, "Valid token with valid login claim", func(t *testing.T, sc *scenarioContext) { @@ -37,6 +42,7 @@ func TestMiddlewareJWTAuth(t *testing.T) { sc.jwtAuthService.VerifyProvider = func(ctx context.Context, token string) (models.JWTClaims, error) { verifiedToken = token return models.JWTClaims{ + "sub": myUsername, "foo-username": myUsername, }, nil } @@ -64,6 +70,7 @@ func TestMiddlewareJWTAuth(t *testing.T) { sc.jwtAuthService.VerifyProvider = func(ctx context.Context, token string) (models.JWTClaims, error) { verifiedToken = token return models.JWTClaims{ + "sub": myEmail, "foo-email": myEmail, }, nil } @@ -85,11 +92,72 @@ func TestMiddlewareJWTAuth(t *testing.T) { assert.Equal(t, myEmail, sc.context.Email) }, configure, configureEmailClaim) + middlewareScenario(t, "Valid token with no user and auto_sign_up disabled", func(t *testing.T, sc *scenarioContext) { + myEmail := "vladimir@example.com" + var verifiedToken string + sc.jwtAuthService.VerifyProvider = func(ctx context.Context, token string) (models.JWTClaims, error) { + verifiedToken = token + return models.JWTClaims{ + "sub": myEmail, + "name": "Vladimir Example", + "foo-email": myEmail, + }, nil + } + bus.AddHandler("get-sign-user", func(ctx context.Context, query *models.GetSignedInUserQuery) error { + return models.ErrUserNotFound + }) + + sc.fakeReq("GET", "/").withJWTAuthHeader(token).exec() + assert.Equal(t, verifiedToken, token) + assert.Equal(t, 401, sc.resp.Code) + assert.Equal(t, contexthandler.UserNotFound, sc.respJson["message"]) + }, configure, configureEmailClaim) + + middlewareScenario(t, "Valid token with no user and auto_sign_up enabled", func(t *testing.T, sc *scenarioContext) { + myEmail := "vladimir@example.com" + var verifiedToken string + sc.jwtAuthService.VerifyProvider = func(ctx context.Context, token string) (models.JWTClaims, error) { + verifiedToken = token + return models.JWTClaims{ + "sub": myEmail, + "name": "Vladimir Example", + "foo-email": myEmail, + }, nil + } + bus.AddHandler("get-sign-user", func(ctx context.Context, query *models.GetSignedInUserQuery) error { + query.Result = &models.SignedInUser{ + UserId: id, + OrgId: orgID, + Email: query.Email, + } + return nil + }) + bus.AddHandler("upsert-user", func(ctx context.Context, command *models.UpsertUserCommand) error { + command.Result = &models.User{ + Id: id, + Name: command.ExternalUser.Name, + Email: command.ExternalUser.Email, + } + return nil + }) + + sc.fakeReq("GET", "/").withJWTAuthHeader(token).exec() + assert.Equal(t, verifiedToken, token) + assert.Equal(t, 200, sc.resp.Code) + assert.True(t, sc.context.IsSignedIn) + assert.Equal(t, orgID, sc.context.OrgId) + assert.Equal(t, id, sc.context.UserId) + assert.Equal(t, myEmail, sc.context.Email) + }, configure, configureEmailClaim, configureAutoSignUp) + middlewareScenario(t, "Valid token without a login claim", func(t *testing.T, sc *scenarioContext) { var verifiedToken string sc.jwtAuthService.VerifyProvider = func(ctx context.Context, token string) (models.JWTClaims, error) { verifiedToken = token - return models.JWTClaims{"foo": "bar"}, nil + return models.JWTClaims{ + "sub": "baz", + "foo": "bar", + }, nil } sc.fakeReq("GET", "/").withJWTAuthHeader(token).exec() @@ -102,7 +170,10 @@ func TestMiddlewareJWTAuth(t *testing.T) { var verifiedToken string sc.jwtAuthService.VerifyProvider = func(ctx context.Context, token string) (models.JWTClaims, error) { verifiedToken = token - return models.JWTClaims{"foo": "bar"}, nil + return models.JWTClaims{ + "sub": "baz", + "foo": "bar", + }, nil } sc.fakeReq("GET", "/").withJWTAuthHeader(token).exec() diff --git a/pkg/services/contexthandler/auth_jwt.go b/pkg/services/contexthandler/auth_jwt.go index 7577b23c80a..748bd13c120 100644 --- a/pkg/services/contexthandler/auth_jwt.go +++ b/pkg/services/contexthandler/auth_jwt.go @@ -9,6 +9,7 @@ import ( ) const InvalidJWT = "Invalid JWT" +const UserNotFound = "User not found" func (h *ContextHandler) initContextWithJWT(ctx *models.ReqContext, orgId int64) bool { if !h.Cfg.JWTAuthEnabled || h.Cfg.JWTAuthHeaderName == "" { @@ -29,11 +30,29 @@ func (h *ContextHandler) initContextWithJWT(ctx *models.ReqContext, orgId int64) query := models.GetSignedInUserQuery{OrgId: orgId} + sub, _ := claims["sub"].(string) + + if sub == "" { + ctx.Logger.Warn("Got a JWT without the mandatory 'sub' claim", "error", err) + ctx.JsonApiErr(401, InvalidJWT, err) + return true + } + extUser := &models.ExternalUserInfo{ + AuthModule: "jwt", + AuthId: sub, + } + if key := h.Cfg.JWTAuthUsernameClaim; key != "" { query.Login, _ = claims[key].(string) + extUser.Login, _ = claims[key].(string) } if key := h.Cfg.JWTAuthEmailClaim; key != "" { query.Email, _ = claims[key].(string) + extUser.Email, _ = claims[key].(string) + } + + if name, _ := claims["name"].(string); name != "" { + extUser.Name = name } if query.Login == "" && query.Email == "" { @@ -42,6 +61,18 @@ func (h *ContextHandler) initContextWithJWT(ctx *models.ReqContext, orgId int64) return true } + if h.Cfg.JWTAuthAutoSignUp { + upsert := &models.UpsertUserCommand{ + ReqContext: ctx, + SignupAllowed: h.Cfg.JWTAuthAutoSignUp, + ExternalUser: extUser, + } + if err := bus.Dispatch(ctx.Req.Context(), upsert); err != nil { + ctx.Logger.Error("Failed to upsert JWT user", "error", err) + return false + } + } + if err := bus.Dispatch(ctx.Req.Context(), &query); err != nil { if errors.Is(err, models.ErrUserNotFound) { ctx.Logger.Debug( @@ -50,10 +81,11 @@ func (h *ContextHandler) initContextWithJWT(ctx *models.ReqContext, orgId int64) "username_claim", query.Login, ) err = login.ErrInvalidCredentials + ctx.JsonApiErr(401, UserNotFound, err) } else { ctx.Logger.Error("Failed to get signed in user", "error", err) + ctx.JsonApiErr(401, InvalidJWT, err) } - ctx.JsonApiErr(401, InvalidJWT, err) return true } diff --git a/pkg/services/searchusers/searchusers.go b/pkg/services/searchusers/searchusers.go index 1cca2ed704e..c6fbfbc9402 100644 --- a/pkg/services/searchusers/searchusers.go +++ b/pkg/services/searchusers/searchusers.go @@ -96,6 +96,8 @@ func GetAuthProviderLabel(authModule string) string { return "SAML" case "ldap", "": return "LDAP" + case "jwt": + return "JWT" default: return "OAuth" } diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 1b5db0e641a..279b2f72c5a 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -319,6 +319,7 @@ type Cfg struct { JWTAuthCacheTTL time.Duration JWTAuthKeyFile string JWTAuthJWKSetFile string + JWTAuthAutoSignUp bool // Dataproxy SendUserHeader bool @@ -1308,6 +1309,7 @@ func readAuthSettings(iniFile *ini.File, cfg *Cfg) (err error) { cfg.JWTAuthCacheTTL = authJWT.Key("cache_ttl").MustDuration(time.Minute * 60) cfg.JWTAuthKeyFile = valueAsString(authJWT, "key_file", "") cfg.JWTAuthJWKSetFile = valueAsString(authJWT, "jwk_set_file", "") + cfg.JWTAuthAutoSignUp = authJWT.Key("auto_sign_up").MustBool(false) authProxy := iniFile.Section("auth.proxy") AuthProxyEnabled = authProxy.Key("enabled").MustBool(false)