Skip to content

Commit 6fb0e8a

Browse files
authored
feat: upgrade existing sessions to v2 refresh tokens though config value (#2356)
If the refresh token algorithm version is set to 2, only new sessions would be using these. By setting `GOTRUE_SECURITY_REFRESH_TOKEN_UPGRADE_PERCENTAGE` to a value between 0 and 100 inclusive, on the next refresh token request a session using a v1 refresh token will switch to using a v2 refresh token. The percentage is to allow for gradual rollout, as the upgrade step can result in some concurrent refreshes to terminate the session early.
1 parent 1ae3a3d commit 6fb0e8a

File tree

4 files changed

+120
-0
lines changed

4 files changed

+120
-0
lines changed

internal/conf/configuration.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -725,6 +725,7 @@ func (c *DatabaseEncryptionConfiguration) Validate() error {
725725

726726
type SecurityConfiguration struct {
727727
Captcha CaptchaConfiguration `json:"captcha"`
728+
RefreshTokenUpgradePercentage int `json:"refresh_token_upgrade_percentage" split_words:"true"`
728729
RefreshTokenAlgorithmVersion int `json:"refresh_token_algorithm_version" split_words:"true"`
729730
RefreshTokenRotationEnabled bool `json:"refresh_token_rotation_enabled" split_words:"true" default:"true"`
730731
RefreshTokenReuseInterval int `json:"refresh_token_reuse_interval" split_words:"true"`
@@ -745,6 +746,14 @@ func (c *SecurityConfiguration) Validate() error {
745746
return err
746747
}
747748

749+
if c.RefreshTokenAlgorithmVersion < 0 || c.RefreshTokenAlgorithmVersion > 2 {
750+
return fmt.Errorf("refresh token algorithm version must be 0, 1 or 2 but was %v", c.RefreshTokenAlgorithmVersion)
751+
}
752+
753+
if c.RefreshTokenUpgradePercentage < 0 || c.RefreshTokenUpgradePercentage > 100 {
754+
return fmt.Errorf("refresh token upgrade percentage must be between 0 and 100, but was %v", c.RefreshTokenUpgradePercentage)
755+
}
756+
748757
return nil
749758
}
750759

internal/models/refresh_token.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,10 @@ func (s *Session) SetupRefreshTokenData(dbEncryption conf.DatabaseEncryptionConf
168168
return nil
169169
}
170170

171+
func (s *Session) UpdateRefreshTokenCounterAndHmacKey(tx *storage.Connection) error {
172+
return tx.UpdateOnly(s, "refresh_token_hmac_key", "refresh_token_counter")
173+
}
174+
171175
func createRefreshToken(tx *storage.Connection, user *User, oldToken *RefreshToken, params *GrantParams) (*RefreshToken, error) {
172176
token := &RefreshToken{
173177
UserID: user.ID,

internal/tokens/service.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,47 @@ func (s *Service) RefreshTokenGrant(ctx context.Context, db *storage.Connection,
413413
}
414414

415415
issuedToken = newToken.Token
416+
417+
shouldUpgrade := config.Security.RefreshTokenAlgorithmVersion == 2 && (config.Security.RefreshTokenUpgradePercentage >= 100 || mathRand.Intn(100) < config.Security.RefreshTokenUpgradePercentage) // #nosec
418+
419+
if shouldUpgrade {
420+
// got v1 refresh token that should be upgraded to v2
421+
// so discard the previously generated v1 token, revoke it and issue a v2 token instead
422+
423+
if session.RefreshTokenHmacKey == nil || session.RefreshTokenCounter == nil {
424+
if serr := session.SetupRefreshTokenData(config.Security.DBEncryption); serr != nil {
425+
return apierrors.NewInternalServerError("failed to set up refresh token data for session").WithInternalError(serr)
426+
}
427+
} else if session.RefreshTokenCounter != nil {
428+
// session already set up, increment the counter by 1
429+
counter := *session.RefreshTokenCounter + 1
430+
session.RefreshTokenCounter = &counter
431+
}
432+
433+
signingKey, _, kerr := session.GetRefreshTokenHmacKey(config.Security.DBEncryption)
434+
if kerr != nil {
435+
return apierrors.NewInternalServerError("failed to load session signing key from database").WithInternalError(kerr)
436+
}
437+
438+
issuedToken = (&crypto.RefreshToken{
439+
Version: 0,
440+
SessionID: session.ID,
441+
Counter: *session.RefreshTokenCounter,
442+
}).Encode(signingKey)
443+
444+
if terr := session.UpdateRefreshTokenCounterAndHmacKey(tx); terr != nil {
445+
return apierrors.NewInternalServerError("failed to set up session with refresh token algorithm v2").WithInternalError(terr)
446+
}
447+
448+
newToken.Revoked = true
449+
if terr := tx.UpdateOnly(newToken, "revoked"); terr != nil {
450+
return apierrors.NewInternalServerError("failed to mark v1 refresh token as revoked").WithInternalError(terr)
451+
}
452+
453+
responseHeaders.Set("sb-auth-refresh-token-counter", strconv.FormatInt(*session.RefreshTokenCounter, 10))
454+
}
455+
456+
responseHeaders.Set("sb-auth-refresh-token-reuse", "false")
416457
}
417458

418459
responseHeaders.Set("sb-auth-refresh-token-prefix", issuedToken[0:5])

internal/tokens/service_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1091,6 +1091,72 @@ func TestAMRClaimUnmarshal(t *testing.T) {
10911091
})
10921092
}
10931093

1094+
func (ts *RefreshTokenV2Suite) TestRefreshTokenVersionUpgrade() {
1095+
config := ts.config()
1096+
1097+
require.Equal(ts.T(), 2, config.Security.RefreshTokenAlgorithmVersion)
1098+
1099+
// start out with version 1, to issue a session with the old refresh tokens
1100+
// which then will be upgraded to the new one
1101+
config.Security.RefreshTokenAlgorithmVersion = 1
1102+
config.Security.RefreshTokenRotationEnabled = false
1103+
config.Security.RefreshTokenReuseInterval = 1
1104+
config.Security.RefreshTokenAllowReuse = false
1105+
1106+
clock := time.Now()
1107+
1108+
srv := NewService(config, &panicHookManager{})
1109+
srv.SetTimeFunc(func() time.Time {
1110+
return clock
1111+
})
1112+
1113+
req, err := http.NewRequest("POST", "https://example.com/", nil)
1114+
require.NoError(ts.T(), err)
1115+
1116+
req = req.WithContext(context.Background())
1117+
responseHeaders := make(http.Header)
1118+
1119+
at, err := srv.IssueRefreshToken(
1120+
req,
1121+
responseHeaders,
1122+
ts.Conn,
1123+
ts.User,
1124+
models.PasswordGrant,
1125+
models.GrantParams{},
1126+
)
1127+
require.NoError(ts.T(), err)
1128+
require.NotNil(ts.T(), at)
1129+
1130+
refreshTokenToUse := at.RefreshToken
1131+
1132+
// now set the algorithm to 2 and start upgrading
1133+
config.Security.RefreshTokenAlgorithmVersion = 2
1134+
config.Security.RefreshTokenUpgradePercentage = 100
1135+
1136+
clock = clock.Add(time.Duration(config.Security.RefreshTokenReuseInterval)*time.Second + time.Duration(100)*time.Millisecond)
1137+
responseHeaders = make(http.Header)
1138+
1139+
nrt, err := srv.RefreshTokenGrant(context.Background(), ts.Conn, req, responseHeaders, RefreshTokenGrantParams{
1140+
RefreshToken: refreshTokenToUse,
1141+
})
1142+
require.NoError(ts.T(), err)
1143+
1144+
pnrt, err := crypto.ParseRefreshToken(nrt.RefreshToken)
1145+
require.NoError(ts.T(), err)
1146+
require.NotNil(ts.T(), pnrt)
1147+
require.Equal(ts.T(), int64(0), pnrt.Counter)
1148+
1149+
refreshedSession, err := models.FindSessionByID(ts.Conn, pnrt.SessionID, false)
1150+
require.NoError(ts.T(), err)
1151+
require.NotNil(ts.T(), refreshedSession.RefreshTokenCounter)
1152+
require.NotNil(ts.T(), refreshedSession.RefreshTokenHmacKey)
1153+
require.Equal(ts.T(), int64(0), *refreshedSession.RefreshTokenCounter)
1154+
1155+
require.Equal(ts.T(), refreshedSession.UserID.String(), responseHeaders.Get("sb-auth-user-id"))
1156+
require.Equal(ts.T(), refreshedSession.ID.String(), responseHeaders.Get("sb-auth-session-id"))
1157+
require.Equal(ts.T(), "0", responseHeaders.Get("sb-auth-refresh-token-counter"))
1158+
}
1159+
10941160
// TestAsRedirectURL tests that AsRedirectURL includes the Supabase Auth identifier
10951161
func TestAsRedirectURL(t *testing.T) {
10961162
response := &AccessTokenResponse{

0 commit comments

Comments
 (0)