From 2f8edecfa0fe056dc00461ab0f51761d02369441 Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Sun, 19 Jan 2025 16:42:10 +0100 Subject: [PATCH 1/4] feat: allow selecting optional claims to include JWTs can be minimal with only required claims set. Add option for user to configure which additional claims to include. --- example.env | 1 + internal/api/token.go | 36 ++++++++++++++++++++++------------ internal/conf/configuration.go | 1 + 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/example.env b/example.env index e645c96e9c..0f9204b0d9 100644 --- a/example.env +++ b/example.env @@ -6,6 +6,7 @@ GOTRUE_JWT_EXP="3600" GOTRUE_JWT_AUD="authenticated" GOTRUE_JWT_DEFAULT_GROUP_NAME="authenticated" GOTRUE_JWT_ADMIN_ROLES="supabase_admin,service_role" +GOTRUE_JWT_ADDITIONAL_CLAIMS="email,phone,app_metadata,user_metadata,amr,is_anonymous" # Database & API connection details GOTRUE_DB_DRIVER="postgres" diff --git a/internal/api/token.go b/internal/api/token.go index cc945f2e13..3b1c992677 100644 --- a/internal/api/token.go +++ b/internal/api/token.go @@ -23,9 +23,9 @@ import ( // AccessTokenClaims is a struct thats used for JWT claims type AccessTokenClaims struct { jwt.RegisteredClaims - Email string `json:"email"` - Phone string `json:"phone"` - AppMetaData map[string]interface{} `json:"app_metadata"` + Email string `json:"email,omitempty"` + Phone string `json:"phone,omitempty"` + AppMetaData map[string]interface{} `json:"app_metadata,omitempty"` UserMetaData map[string]interface{} `json:"user_metadata"` Role string `json:"role"` AuthenticatorAssuranceLevel string `json:"aal,omitempty"` @@ -333,15 +333,27 @@ func (a *API) generateAccessToken(r *http.Request, tx *storage.Connection, user ExpiresAt: jwt.NewNumericDate(expiresAt), Issuer: config.JWT.Issuer, }, - Email: user.GetEmail(), - Phone: user.GetPhone(), - AppMetaData: user.AppMetaData, - UserMetaData: user.UserMetaData, - Role: user.Role, - SessionId: sid, - AuthenticatorAssuranceLevel: aal.String(), - AuthenticationMethodReference: amr, - IsAnonymous: user.IsAnonymous, + AuthenticatorAssuranceLevel: aal.String(), + SessionId: sid, + Role: user.Role, + } + + // add additional claims that are optional + for _, rc := range config.JWT.AdditionalClaims { + switch rc { + case "email": + claims.Email = user.GetEmail() + case "phone": + claims.Phone = user.GetPhone() + case "app_metadata": + claims.AppMetaData = user.AppMetaData + case "user_metadata": + claims.UserMetaData = user.UserMetaData + case "amr": + claims.AuthenticationMethodReference = amr + case "is_anonymous": + claims.IsAnonymous = user.IsAnonymous + } } var gotrueClaims jwt.Claims = claims diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index c4d910d991..6eaf82aa81 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -111,6 +111,7 @@ type JWTConfiguration struct { KeyID string `json:"key_id" split_words:"true"` Keys JwtKeysDecoder `json:"keys"` ValidMethods []string `json:"-"` + AdditionalClaims []string `json:"additional_claims" split_words:"true"` } type MFAFactorTypeConfiguration struct { From e067caa17ebc690027d9b57b1abf22ad2e918b89 Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Sun, 19 Jan 2025 17:54:42 +0100 Subject: [PATCH 2/4] feat: keep backwards compatibility through default claims --- internal/conf/configuration.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 6eaf82aa81..474a90d666 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -887,6 +887,15 @@ func (config *GlobalConfiguration) ApplyDefaults() error { config.JWT.AdminRoles = []string{"service_role", "supabase_admin"} } + // default to all claims that were / are available at the time of this change + // to ensure backwards compatibility. To exclude all these claims, the value + // of jwt.additional_claims can be set to an invalid claim, such as "none", "empty", "null" + // also allow setting to default claims using the "default" keyword, making it possible to use + // this config as a binary flag "none" == use_mimimal_jwt == true, "default" == use_mimimal_jwt == false + if len(config.JWT.AdditionalClaims) == 0 || (len(config.JWT.AdditionalClaims) == 1 && config.JWT.AdditionalClaims[0] == "default") { + config.JWT.AdditionalClaims = []string{"email", "phone", "app_metadata", "user_metadata", "amr", "is_anonymous"} + } + if config.JWT.Exp == 0 { config.JWT.Exp = 3600 } From 11269633cea6e90406d720b9f9bc162f8340f3d8 Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Mon, 20 Jan 2025 11:39:00 +0100 Subject: [PATCH 3/4] tests: verify expected claims are present --- internal/api/token.go | 6 ++-- internal/api/token_test.go | 64 ++++++++++++++++++++++++++++++++++++ internal/hooks/auth_hooks.go | 8 ++--- 3 files changed, 71 insertions(+), 7 deletions(-) diff --git a/internal/api/token.go b/internal/api/token.go index 3b1c992677..64231bf5ef 100644 --- a/internal/api/token.go +++ b/internal/api/token.go @@ -31,7 +31,7 @@ type AccessTokenClaims struct { AuthenticatorAssuranceLevel string `json:"aal,omitempty"` AuthenticationMethodReference []models.AMREntry `json:"amr,omitempty"` SessionId string `json:"session_id,omitempty"` - IsAnonymous bool `json:"is_anonymous"` + IsAnonymous bool `json:"is_anonymous,omitempty"` } // AccessTokenResponse represents an OAuth2 success response @@ -339,8 +339,8 @@ func (a *API) generateAccessToken(r *http.Request, tx *storage.Connection, user } // add additional claims that are optional - for _, rc := range config.JWT.AdditionalClaims { - switch rc { + for _, ac := range config.JWT.AdditionalClaims { + switch ac { case "email": claims.Email = user.GetEmail() case "phone": diff --git a/internal/api/token_test.go b/internal/api/token_test.go index fc89d4f8bf..4305e5d7ac 100644 --- a/internal/api/token_test.go +++ b/internal/api/token_test.go @@ -855,3 +855,67 @@ $$;` }) } } + +func (ts *TokenTestSuite) TestConfigureAccessToken() { + type customAccessTokenTestcase struct { + desc string + additionalClaimsConfig []string + expectedClaims []string + } + requiredClaims := []string{"aud", "exp", "iat", "sub", "role", "aal", "session_id", "user_metadata"} + cases := []customAccessTokenTestcase{ + + { + desc: "Default claims all present", + additionalClaimsConfig: []string{"empty"}, + expectedClaims: requiredClaims, + }, { + desc: "Minimal set of claims is returned", + additionalClaimsConfig: []string{}, + expectedClaims: requiredClaims, + }, + { + desc: "Selected additional claims are returned", + additionalClaimsConfig: []string{"email"}, + expectedClaims: append(requiredClaims, []string{"email"}...), + }, + } + for _, c := range cases { + ts.T().Run(c.desc, func(t *testing.T) { + ts.Config.JWT.AdditionalClaims = c.additionalClaimsConfig + + var buffer bytes.Buffer + require.NoError(t, json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "refresh_token": ts.RefreshToken.Token, + })) + + req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=refresh_token", &buffer) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + + var tokenResponse struct { + AccessToken string `json:"access_token"` + } + require.NoError(t, json.NewDecoder(w.Result().Body).Decode(&tokenResponse)) + parts := strings.Split(tokenResponse.AccessToken, ".") + require.Equal(t, 3, len(parts), "Token should have 3 parts") + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + require.NoError(t, err) + + var responseClaims map[string]interface{} + require.NoError(t, json.Unmarshal(payload, &responseClaims)) + + assert.Len(t, responseClaims, len(c.expectedClaims), "More or less claims in the response than expected") + for _, expectedClaim := range c.expectedClaims { + _, exists := responseClaims[expectedClaim] + assert.True(t, exists, "Claim should exist {%s}", expectedClaim) + } + + // reset + ts.Config.JWT.AdditionalClaims = []string{"email", "phone", "app_metadata", "user_metadata", "amr", "is_anonymous"} + }) + } +} diff --git a/internal/hooks/auth_hooks.go b/internal/hooks/auth_hooks.go index 1b881d36f2..50df403efc 100644 --- a/internal/hooks/auth_hooks.go +++ b/internal/hooks/auth_hooks.go @@ -100,15 +100,15 @@ const MinimumViableTokenSchema = `{ // AccessTokenClaims is a struct thats used for JWT claims type AccessTokenClaims struct { jwt.RegisteredClaims - Email string `json:"email"` - Phone string `json:"phone"` - AppMetaData map[string]interface{} `json:"app_metadata"` + Email string `json:"email,omitempty"` + Phone string `json:"phone,omitempty"` + AppMetaData map[string]interface{} `json:"app_metadata,omitempty"` UserMetaData map[string]interface{} `json:"user_metadata"` Role string `json:"role"` AuthenticatorAssuranceLevel string `json:"aal,omitempty"` AuthenticationMethodReference []models.AMREntry `json:"amr,omitempty"` SessionId string `json:"session_id,omitempty"` - IsAnonymous bool `json:"is_anonymous"` + IsAnonymous bool `json:"is_anonymous,omitempty"` } type MFAVerificationAttemptInput struct { From 3a94975ff6ef0a35175a250b203e9f058a08e097 Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Mon, 20 Jan 2025 12:41:08 +0100 Subject: [PATCH 4/4] fix: json schema validation --- internal/api/token.go | 5 ++--- internal/api/token_test.go | 2 +- internal/conf/configuration.go | 2 +- internal/hooks/auth_hooks.go | 4 ++-- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/internal/api/token.go b/internal/api/token.go index 64231bf5ef..96db0819e3 100644 --- a/internal/api/token.go +++ b/internal/api/token.go @@ -31,7 +31,7 @@ type AccessTokenClaims struct { AuthenticatorAssuranceLevel string `json:"aal,omitempty"` AuthenticationMethodReference []models.AMREntry `json:"amr,omitempty"` SessionId string `json:"session_id,omitempty"` - IsAnonymous bool `json:"is_anonymous,omitempty"` + IsAnonymous bool `json:"is_anonymous"` } // AccessTokenResponse represents an OAuth2 success response @@ -336,6 +336,7 @@ func (a *API) generateAccessToken(r *http.Request, tx *storage.Connection, user AuthenticatorAssuranceLevel: aal.String(), SessionId: sid, Role: user.Role, + IsAnonymous: user.IsAnonymous, } // add additional claims that are optional @@ -351,8 +352,6 @@ func (a *API) generateAccessToken(r *http.Request, tx *storage.Connection, user claims.UserMetaData = user.UserMetaData case "amr": claims.AuthenticationMethodReference = amr - case "is_anonymous": - claims.IsAnonymous = user.IsAnonymous } } diff --git a/internal/api/token_test.go b/internal/api/token_test.go index 4305e5d7ac..75afc91881 100644 --- a/internal/api/token_test.go +++ b/internal/api/token_test.go @@ -862,7 +862,7 @@ func (ts *TokenTestSuite) TestConfigureAccessToken() { additionalClaimsConfig []string expectedClaims []string } - requiredClaims := []string{"aud", "exp", "iat", "sub", "role", "aal", "session_id", "user_metadata"} + requiredClaims := []string{"aud", "exp", "iat", "sub", "role", "aal", "session_id", "user_metadata", "is_anonymous"} cases := []customAccessTokenTestcase{ { diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 474a90d666..78234f7d24 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -893,7 +893,7 @@ func (config *GlobalConfiguration) ApplyDefaults() error { // also allow setting to default claims using the "default" keyword, making it possible to use // this config as a binary flag "none" == use_mimimal_jwt == true, "default" == use_mimimal_jwt == false if len(config.JWT.AdditionalClaims) == 0 || (len(config.JWT.AdditionalClaims) == 1 && config.JWT.AdditionalClaims[0] == "default") { - config.JWT.AdditionalClaims = []string{"email", "phone", "app_metadata", "user_metadata", "amr", "is_anonymous"} + config.JWT.AdditionalClaims = []string{"email", "phone", "app_metadata", "user_metadata", "amr"} } if config.JWT.Exp == 0 { diff --git a/internal/hooks/auth_hooks.go b/internal/hooks/auth_hooks.go index 50df403efc..59c1517439 100644 --- a/internal/hooks/auth_hooks.go +++ b/internal/hooks/auth_hooks.go @@ -94,7 +94,7 @@ const MinimumViableTokenSchema = `{ "type": "string" } }, - "required": ["aud", "exp", "iat", "sub", "email", "phone", "role", "aal", "session_id", "is_anonymous"] + "required": ["aud", "exp", "iat", "sub", "role", "aal", "session_id"] }` // AccessTokenClaims is a struct thats used for JWT claims @@ -108,7 +108,7 @@ type AccessTokenClaims struct { AuthenticatorAssuranceLevel string `json:"aal,omitempty"` AuthenticationMethodReference []models.AMREntry `json:"amr,omitempty"` SessionId string `json:"session_id,omitempty"` - IsAnonymous bool `json:"is_anonymous,omitempty"` + IsAnonymous bool `json:"is_anonymous"` } type MFAVerificationAttemptInput struct {