Skip to content

Commit dea5b8e

Browse files
authored
feat: introduce v2 refresh token algorithm (#2216)
Introduces v2 of a refresh token algorithm. **Goals** 1. **Smaller disk size.** A common complaint with the `refresh_tokens` table is that it's huge and difficult to clean up without impacting IO performance. 2. **Lighter on replication.** A lot of active users cause a lot of on-wire traffic for replication. 3. **Easier to debug and analyze.** Refresh tokens are not transparent, and a recursive self-relationship is not easy to debug with difficult edge cases that are not local in time. **Configuration Options** | Config | Meaning | | --- | --- | | `GOTRUE_SECURITY_REFRESH_TOKEN_ALGORITHM_VERSION` | 0 or 1 for regular tokens, 2 for new tokens. This currently only applies on new sessions. Old sessions use old implementation. | | `GOTRUE_SECURITY_REFRESH_TOKEN_ALLOW_REUSE` | not secure, but allows any v2 refresh token to always be reused. | **Implementation** Refresh tokens now encode the session ID + counter value, which is then signed by a per-session HMAC key. By comparing the state in the session and the counter in the refresh token, we can identify whether a refresh token is being used properly or it's being reused. We can also identify what type of reuse is going on. If the refresh token counter is the previous refresh token, it means the client failed to save the last response. This is always allowed. If the refresh token counter is older than the previous refresh token, then it's being reused. Reuse is allowed only if the client is refreshing the same session within the `GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL`. To ensure a non-synchronized client synchronizes to the correct refresh token state, the session's refresh token counter is incremented only on valid refresh token use. Please check the massive suite of tests (>700 LOC) which exercise every possible combination to ensure correctness of the algorithm. The coverage here is above 95% (of the lines that can be covered). **Observability and Debuging** Requests that create a session and refresh a session will now receive these response headers: | Header | Meaning | | --- | --- | | `sb-auth-user-id` | the user to which the session belongs | | `sb-auth-session-id` | the newly created or refreshed session | | `sb-auth-refresh-token-counter` | (v2 only) the refresh token's counter that is being returned | | `sb-auth-refresh-token-prefix` | (v1 only) the first 5 characters of the refresh token being returned | | `sb-auth-refresh-token-reuse-cause` | (v2 only) a comma separated list explaining what type of reuse was detected | `fail-to-save`, `concurrent-refresh`, `always-reuse` | | `sb-auth-refresh-token-rotation` | (v2 only) if the session is being terminated due to detected malicious refresh token reuse |
1 parent cf39a8a commit dea5b8e

18 files changed

+1527
-90
lines changed

internal/api/oauthserver/handlers.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -437,7 +437,7 @@ func (s *Server) handleAuthorizationCodeGrant(ctx context.Context, w http.Respon
437437

438438
// Issue the refresh token and access token
439439
var terr error
440-
tokenResponse, terr = tokenService.IssueRefreshToken(r, tx, user, authMethod, grantParams)
440+
tokenResponse, terr = tokenService.IssueRefreshToken(r, w.Header(), tx, user, authMethod, grantParams)
441441
if terr != nil {
442442
return terr
443443
}
@@ -488,7 +488,7 @@ func (s *Server) handleRefreshTokenGrant(ctx context.Context, w http.ResponseWri
488488
}
489489

490490
db := s.db.WithContext(ctx)
491-
tokenResponse, err := tokenService.RefreshTokenGrant(ctx, db, r, tokens.RefreshTokenGrantParams{
491+
tokenResponse, err := tokenService.RefreshTokenGrant(ctx, db, r, w.Header(), tokens.RefreshTokenGrantParams{
492492
RefreshToken: params.RefreshToken,
493493
ClientID: clientID,
494494
})

internal/api/token.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ func (a *API) ResourceOwnerPasswordGrant(ctx context.Context, w http.ResponseWri
190190
}); terr != nil {
191191
return terr
192192
}
193-
token, terr = a.tokenService.IssueRefreshToken(r, tx, user, models.PasswordGrant, grantParams)
193+
token, terr = a.tokenService.IssueRefreshToken(r, w.Header(), tx, user, models.PasswordGrant, grantParams)
194194
if terr != nil {
195195
return terr
196196
}
@@ -260,7 +260,7 @@ func (a *API) PKCE(ctx context.Context, w http.ResponseWriter, r *http.Request)
260260
}); terr != nil {
261261
return terr
262262
}
263-
token, terr = a.tokenService.IssueRefreshToken(r, tx, user, authMethod, grantParams)
263+
token, terr = a.tokenService.IssueRefreshToken(r, w.Header(), tx, user, authMethod, grantParams)
264264
if terr != nil {
265265
// error type is already handled in issueRefreshToken
266266
return terr
@@ -295,7 +295,7 @@ func (a *API) generateAccessToken(r *http.Request, tx *storage.Connection, user
295295
}
296296

297297
func (a *API) issueRefreshToken(r *http.Request, conn *storage.Connection, user *models.User, authenticationMethod models.AuthenticationMethod, grantParams models.GrantParams) (*tokens.AccessTokenResponse, error) {
298-
return a.tokenService.IssueRefreshToken(r, conn, user, authenticationMethod, grantParams)
298+
return a.tokenService.IssueRefreshToken(r, make(http.Header), conn, user, authenticationMethod, grantParams)
299299
}
300300

301301
func (a *API) updateMFASessionAndClaims(r *http.Request, tx *storage.Connection, user *models.User, authenticationMethod models.AuthenticationMethod, grantParams models.GrantParams) (*tokens.AccessTokenResponse, error) {

internal/api/token_refresh.go

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ package api
33
import (
44
"context"
55
"net/http"
6+
"regexp"
67

8+
"github.com/supabase/auth/internal/api/apierrors"
9+
"github.com/supabase/auth/internal/crypto"
710
"github.com/supabase/auth/internal/tokens"
811
)
912

@@ -12,15 +15,42 @@ type RefreshTokenGrantParams struct {
1215
RefreshToken string `json:"refresh_token"`
1316
}
1417

18+
var legacyRefreshTokenPattern = regexp.MustCompile("^[a-z0-9]{12}$")
19+
20+
func (p *RefreshTokenGrantParams) Validate() error {
21+
if len(p.RefreshToken) < 12 {
22+
return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Refresh token is not valid")
23+
}
24+
25+
if len(p.RefreshToken) == 12 {
26+
if !legacyRefreshTokenPattern.MatchString(p.RefreshToken) {
27+
return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Refresh token is not valid")
28+
}
29+
30+
return nil
31+
}
32+
33+
_, err := crypto.ParseRefreshToken(p.RefreshToken)
34+
if err != nil {
35+
return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Refresh token is not valid").WithInternalError(err)
36+
}
37+
38+
return nil
39+
}
40+
1541
// RefreshTokenGrant implements the refresh_token grant type flow
1642
func (a *API) RefreshTokenGrant(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
1743
params := &RefreshTokenGrantParams{}
1844
if err := retrieveRequestParams(r, params); err != nil {
1945
return err
2046
}
2147

48+
if err := params.Validate(); err != nil {
49+
return err
50+
}
51+
2252
db := a.db.WithContext(ctx)
23-
tokenResponse, err := a.tokenService.RefreshTokenGrant(ctx, db, r, tokens.RefreshTokenGrantParams{
53+
tokenResponse, err := a.tokenService.RefreshTokenGrant(ctx, db, r, w.Header(), tokens.RefreshTokenGrantParams{
2454
RefreshToken: params.RefreshToken,
2555
})
2656
if err != nil {

internal/api/token_test.go

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/stretchr/testify/suite"
2020
"github.com/supabase/auth/internal/api/apierrors"
2121
"github.com/supabase/auth/internal/conf"
22+
"github.com/supabase/auth/internal/crypto"
2223
"github.com/supabase/auth/internal/models"
2324
)
2425

@@ -435,9 +436,11 @@ func (ts *TokenTestSuite) TestRefreshTokenReuseRevocation() {
435436

436437
// ensure that the 4 refresh tokens are setup correctly
437438
for i, refreshToken := range refreshTokens {
438-
_, token, _, err := models.FindUserWithRefreshToken(ts.API.db, refreshToken, false)
439+
_, anyToken, _, err := models.FindUserWithRefreshToken(ts.API.db, ts.Config.Security.DBEncryption, refreshToken, false)
439440
require.NoError(ts.T(), err)
440441

442+
token := anyToken.(*models.RefreshToken)
443+
441444
if i == len(refreshTokens)-1 {
442445
require.False(ts.T(), token.Revoked)
443446
} else {
@@ -470,9 +473,10 @@ func (ts *TokenTestSuite) TestRefreshTokenReuseRevocation() {
470473

471474
// ensure that the refresh tokens are marked as revoked in the database
472475
for _, refreshToken := range refreshTokens {
473-
_, token, _, err := models.FindUserWithRefreshToken(ts.API.db, refreshToken, false)
476+
_, anyToken, _, err := models.FindUserWithRefreshToken(ts.API.db, ts.Config.Security.DBEncryption, refreshToken, false)
474477
require.NoError(ts.T(), err)
475478

479+
token := anyToken.(*models.RefreshToken)
476480
require.True(ts.T(), token.Revoked)
477481
}
478482

@@ -887,3 +891,26 @@ $$;`
887891
})
888892
}
889893
}
894+
895+
func TestRefreshTokenGrantParamsValidate(t *testing.T) {
896+
examples := []string{
897+
"",
898+
"01234567890",
899+
"AAAAAAAAAAAA",
900+
"------------",
901+
"0000000000000",
902+
}
903+
904+
p := &RefreshTokenGrantParams{}
905+
906+
for _, example := range examples {
907+
p.RefreshToken = example
908+
require.Error(t, p.Validate())
909+
}
910+
911+
p.RefreshToken = "0123456abcde"
912+
require.NoError(t, p.Validate())
913+
914+
p.RefreshToken = (&crypto.RefreshToken{}).Encode(make([]byte, 32))
915+
require.NoError(t, p.Validate())
916+
}

internal/conf/configuration.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -723,8 +723,10 @@ func (c *DatabaseEncryptionConfiguration) Validate() error {
723723

724724
type SecurityConfiguration struct {
725725
Captcha CaptchaConfiguration `json:"captcha"`
726+
RefreshTokenAlgorithmVersion int `json:"refresh_token_algorithm_version" split_words:"true"`
726727
RefreshTokenRotationEnabled bool `json:"refresh_token_rotation_enabled" split_words:"true" default:"true"`
727728
RefreshTokenReuseInterval int `json:"refresh_token_reuse_interval" split_words:"true"`
729+
RefreshTokenAllowReuse bool `json:"refresh_token_allow_reuse" split_words:"true"`
728730
UpdatePasswordRequireReauthentication bool `json:"update_password_require_reauthentication" split_words:"true"`
729731
ManualLinkingEnabled bool `json:"manual_linking_enabled" split_words:"true" default:"false"`
730732

internal/crypto/crypto.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ func GenerateOtp(digits int) string {
2929

3030
return otp
3131
}
32+
3233
func GenerateTokenHash(emailOrPhone, otp string) string {
3334
return fmt.Sprintf("%x", sha256.Sum224([]byte(emailOrPhone+otp)))
3435
}

internal/crypto/crypto_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,4 +105,5 @@ func TestEncryptedStringDecryptNegative(t *testing.T) {
105105

106106
func TestSecureToken(t *testing.T) {
107107
assert.Equal(t, len(SecureAlphanumeric(22)), 22)
108+
assert.Equal(t, len(SecureAlphanumeric(7)), 8)
108109
}

internal/crypto/refresh_tokens.go

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
package crypto
2+
3+
import (
4+
"crypto/hmac"
5+
"crypto/rand"
6+
"crypto/sha256"
7+
"crypto/subtle"
8+
"encoding/base64"
9+
"encoding/binary"
10+
"errors"
11+
"math"
12+
13+
"github.com/gofrs/uuid"
14+
)
15+
16+
func GenerateRefreshTokenHmacKey() []byte {
17+
key := make([]byte, 32)
18+
must(rand.Read(key))
19+
20+
return key
21+
}
22+
23+
const refreshTokenChecksumLength = 4
24+
const refreshTokenSignatureLength = 16
25+
const minRefreshTokenLength = 1 + 16 + 1 + refreshTokenSignatureLength + refreshTokenChecksumLength
26+
const maxRefreshTokenLength = minRefreshTokenLength + 8
27+
28+
// RefreshToken is an object that encodes a cryptographically authenticated
29+
// (signed) message containing a version, session ID and monotonically
30+
// increasing non-negative counter.
31+
//
32+
// The signature is a truncated (first 128 bits) of HMAC-SHA-256, which saves
33+
// on encoded length without sacrificing security. The checksum of 4 bytes at
34+
// the end is to lessen the load on the server with invalid strings (those that
35+
// are not likely to be a proper refresh token).
36+
type RefreshToken struct {
37+
Raw []byte
38+
39+
Version byte
40+
SessionID uuid.UUID
41+
Counter int64
42+
Signature []byte
43+
}
44+
45+
func (RefreshToken) TableName() string {
46+
panic("crypto.RefreshToken is not meant to be saved in the database")
47+
}
48+
49+
func (r *RefreshToken) CheckSignature(hmacSha256Key []byte) bool {
50+
bytes := r.Raw[:len(r.Raw)-refreshTokenSignatureLength-refreshTokenChecksumLength]
51+
52+
h := hmac.New(sha256.New, hmacSha256Key)
53+
h.Write(bytes)
54+
signature := h.Sum(nil)[:refreshTokenSignatureLength]
55+
56+
return hmac.Equal(signature, r.Signature)
57+
}
58+
59+
func (r *RefreshToken) Encode(hmacSha256Key []byte) string {
60+
result := make([]byte, 0, maxRefreshTokenLength)
61+
62+
result = append(result, 0)
63+
result = append(result, r.SessionID.Bytes()...)
64+
result = binary.AppendUvarint(result, safeUint64(r.Counter))
65+
66+
// Note on truncating the HMAC-SHA-256 output:
67+
// This does not impact security as the brute-force space is 2^128 and
68+
// the collision space is 2^64, both unattainable in practice.
69+
70+
h := hmac.New(sha256.New, hmacSha256Key)
71+
h.Write(result)
72+
signature := h.Sum(nil)[:refreshTokenSignatureLength]
73+
74+
result = append(result, signature...)
75+
76+
checksum := sha256.Sum256(result)
77+
result = append(result, checksum[:refreshTokenChecksumLength]...)
78+
79+
r.Version = 0
80+
r.Raw = result
81+
r.Signature = signature
82+
83+
return base64.RawURLEncoding.EncodeToString(result)
84+
}
85+
86+
var (
87+
ErrRefreshTokenLength = errors.New("crypto: refresh token length is not valid")
88+
ErrRefreshTokenUnknownVersion = errors.New("crypto: refresh token version is not 0")
89+
ErrRefreshTokenChecksumInvalid = errors.New("crypto: refresh token checksum is not valid")
90+
ErrRefreshTokenCounterInvalid = errors.New("crypto: refresh token's counter is not valid")
91+
)
92+
93+
func safeInt64(v uint64) int64 {
94+
if v > math.MaxInt64 {
95+
return math.MaxInt64
96+
}
97+
98+
return int64(v)
99+
}
100+
101+
func safeUint64(v int64) uint64 {
102+
if v < 0 {
103+
return 0
104+
}
105+
106+
return uint64(v)
107+
}
108+
109+
func ParseRefreshToken(token string) (*RefreshToken, error) {
110+
bytes, err := base64.RawURLEncoding.DecodeString(token)
111+
if err != nil {
112+
return nil, err
113+
}
114+
115+
if len(bytes) < minRefreshTokenLength {
116+
return nil, ErrRefreshTokenLength
117+
}
118+
119+
if bytes[0] != 0 {
120+
return nil, ErrRefreshTokenUnknownVersion
121+
}
122+
123+
parseFrom := bytes[1 : len(bytes)-refreshTokenChecksumLength]
124+
125+
checksum256 := sha256.Sum256(bytes[:len(bytes)-refreshTokenChecksumLength])
126+
if subtle.ConstantTimeCompare(checksum256[:refreshTokenChecksumLength], bytes[len(bytes)-refreshTokenChecksumLength:]) != 1 {
127+
return nil, ErrRefreshTokenChecksumInvalid
128+
}
129+
130+
sessionID := uuid.FromBytesOrNil(parseFrom[0:16])
131+
132+
parseFrom = parseFrom[16:]
133+
134+
counter, counterBytes := binary.Uvarint(parseFrom)
135+
if counterBytes <= 0 {
136+
return nil, ErrRefreshTokenCounterInvalid
137+
}
138+
139+
parseFrom = parseFrom[counterBytes:]
140+
141+
if len(parseFrom) != 16 {
142+
return nil, ErrRefreshTokenLength
143+
}
144+
145+
signature := parseFrom
146+
147+
return &RefreshToken{
148+
Raw: bytes,
149+
150+
Version: 0,
151+
SessionID: sessionID,
152+
Counter: safeInt64(counter),
153+
Signature: signature,
154+
}, nil
155+
}

0 commit comments

Comments
 (0)