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..96db0819e3 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,26 @@ 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, + IsAnonymous: user.IsAnonymous, + } + + // add additional claims that are optional + for _, ac := range config.JWT.AdditionalClaims { + switch ac { + 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 + } } var gotrueClaims jwt.Claims = claims diff --git a/internal/api/token_test.go b/internal/api/token_test.go index fc89d4f8bf..75afc91881 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", "is_anonymous"} + 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/conf/configuration.go b/internal/conf/configuration.go index c4d910d991..78234f7d24 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 { @@ -886,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"} + } + if config.JWT.Exp == 0 { config.JWT.Exp = 3600 } diff --git a/internal/hooks/auth_hooks.go b/internal/hooks/auth_hooks.go index 1b881d36f2..59c1517439 100644 --- a/internal/hooks/auth_hooks.go +++ b/internal/hooks/auth_hooks.go @@ -94,15 +94,15 @@ 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 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"`