Skip to content

Commit 6a6e3be

Browse files
authored
fix: add reuse interval for token refresh (#466)
* add reuse interval to config * add test for refresh token reuse detection * fix: add reuse interval to refresh token grant * add tests for reuse interval * refactor token test * ignore reuse interval if revoked token is last token * add test case * refactor query to get child token * remove unnecessary check in refresh token grant * update readme * update example env file
1 parent 48d6554 commit 6a6e3be

File tree

6 files changed

+159
-32
lines changed

6 files changed

+159
-32
lines changed

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@ Rate limit the number of emails sent per hr on the following endpoints: `/signup
6666

6767
Minimum password length, defaults to 6.
6868

69+
`GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED` - `bool`
70+
71+
If refresh token rotation is enabled, gotrue will automatically detect malicious attempts to reuse a revoked refresh token. When a malicious attempt is detected, gotrue immediately revokes all tokens that descended from the offending token.
72+
73+
`GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL` - `string`
74+
75+
This setting is only applicable if `GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED` is enabled. The reuse interval for a refresh token allows for exchanging the refresh token multiple times during the interval to support concurrency or offline issues. During the reuse interval, gotrue will not consider using a revoked token as a malicious attempt and will simply return the child refresh token.
76+
77+
Only the previous revoked token can be reused. Using an old refresh token way before the current valid refresh token will trigger the reuse detection.
6978
### API
7079

7180
```properties

api/token.go

Lines changed: 39 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -273,41 +273,50 @@ func (a *API) RefreshTokenGrant(ctx context.Context, w http.ResponseWriter, r *h
273273
return oauthError("invalid_grant", "Invalid Refresh Token")
274274
}
275275

276-
if !(config.External.Email.Enabled && config.External.Phone.Enabled) {
277-
providers, err := models.FindProvidersByUser(a.db, user)
278-
if err != nil {
279-
return internalServerError(err.Error())
280-
}
281-
for _, provider := range providers {
282-
if provider == "email" && !config.External.Email.Enabled {
283-
return badRequestError("Email logins are disabled")
276+
var newToken *models.RefreshToken
277+
if token.Revoked {
278+
a.clearCookieTokens(config, w)
279+
err = a.db.Transaction(func(tx *storage.Connection) error {
280+
validToken, terr := models.GetValidChildToken(tx, token)
281+
if terr != nil {
282+
if errors.Is(terr, models.RefreshTokenNotFoundError{}) {
283+
// revoked token has no descendants
284+
return nil
285+
}
286+
return terr
284287
}
285-
if provider == "phone" && !config.External.Phone.Enabled {
286-
return badRequestError("Phone logins are disabled")
288+
// check if token is the last previous revoked token
289+
if validToken.Parent == storage.NullString(token.Token) {
290+
refreshTokenReuseWindow := token.UpdatedAt.Add(time.Second * time.Duration(config.Security.RefreshTokenReuseInterval))
291+
if time.Now().Before(refreshTokenReuseWindow) {
292+
newToken = validToken
293+
}
287294
}
295+
return nil
296+
})
297+
if err != nil {
298+
return internalServerError("Error validating reuse interval").WithInternalError(err)
288299
}
289-
}
290300

291-
if token.Revoked {
292-
a.clearCookieTokens(config, w)
293-
if config.Security.RefreshTokenRotationEnabled {
294-
// Revoke all tokens in token family
295-
err = a.db.Transaction(func(tx *storage.Connection) error {
296-
var terr error
297-
if terr = models.RevokeTokenFamily(tx, token); terr != nil {
298-
return terr
301+
if newToken == nil {
302+
if config.Security.RefreshTokenRotationEnabled {
303+
// Revoke all tokens in token family
304+
err = a.db.Transaction(func(tx *storage.Connection) error {
305+
var terr error
306+
if terr = models.RevokeTokenFamily(tx, token); terr != nil {
307+
return terr
308+
}
309+
return nil
310+
})
311+
if err != nil {
312+
return internalServerError(err.Error())
299313
}
300-
return nil
301-
})
302-
if err != nil {
303-
return internalServerError(err.Error())
304314
}
315+
return oauthError("invalid_grant", "Invalid Refresh Token").WithInternalMessage("Possible abuse attempt: %v", r)
305316
}
306-
return oauthError("invalid_grant", "Invalid Refresh Token").WithInternalMessage("Possible abuse attempt: %v", r)
307317
}
308318

309319
var tokenString string
310-
var newToken *models.RefreshToken
311320
var newTokenResponse *AccessTokenResponse
312321

313322
err = a.db.Transaction(func(tx *storage.Connection) error {
@@ -316,9 +325,11 @@ func (a *API) RefreshTokenGrant(ctx context.Context, w http.ResponseWriter, r *h
316325
return terr
317326
}
318327

319-
newToken, terr = models.GrantRefreshTokenSwap(tx, user, token)
320-
if terr != nil {
321-
return internalServerError(terr.Error())
328+
if newToken == nil {
329+
newToken, terr = models.GrantRefreshTokenSwap(tx, user, token)
330+
if terr != nil {
331+
return internalServerError(terr.Error())
332+
}
322333
}
323334

324335
tokenString, terr = generateAccessToken(user, time.Second*time.Duration(config.JWT.Exp), config.JWT.Secret)

api/token_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,95 @@ func (ts *TokenTestSuite) TestTokenRefreshTokenGrantFailure() {
150150
assert.Equal(ts.T(), http.StatusBadRequest, w.Code)
151151
}
152152

153+
func (ts *TokenTestSuite) TestTokenRefreshTokenRotation() {
154+
u, err := models.NewUser(ts.instanceID, "[email protected]", "password", ts.Config.JWT.Aud, nil)
155+
require.NoError(ts.T(), err, "Error creating test user model")
156+
t := time.Now()
157+
u.EmailConfirmedAt = &t
158+
require.NoError(ts.T(), ts.API.db.Create(u), "Error saving foo user")
159+
first, err := models.GrantAuthenticatedUser(ts.API.db, u)
160+
require.NoError(ts.T(), err)
161+
second, err := models.GrantRefreshTokenSwap(ts.API.db, u, first)
162+
require.NoError(ts.T(), err)
163+
third, err := models.GrantRefreshTokenSwap(ts.API.db, u, second)
164+
require.NoError(ts.T(), err)
165+
166+
cases := []struct {
167+
desc string
168+
refreshTokenRotationEnabled bool
169+
reuseInterval int
170+
refreshToken string
171+
expectedCode int
172+
expectedBody map[string]interface{}
173+
}{
174+
{
175+
"Valid refresh within reuse interval",
176+
true,
177+
30,
178+
second.Token,
179+
http.StatusOK,
180+
map[string]interface{}{
181+
"refresh_token": third.Token,
182+
},
183+
},
184+
{
185+
"Invalid refresh, first token is not the previous revoked token",
186+
true,
187+
0,
188+
first.Token,
189+
http.StatusBadRequest,
190+
map[string]interface{}{
191+
"error": "invalid_grant",
192+
"error_description": "Invalid Refresh Token",
193+
},
194+
},
195+
{
196+
"Invalid refresh, revoked third token",
197+
true,
198+
0,
199+
second.Token,
200+
http.StatusBadRequest,
201+
map[string]interface{}{
202+
"error": "invalid_grant",
203+
"error_description": "Invalid Refresh Token",
204+
},
205+
},
206+
{
207+
"Invalid refresh, third token revoked by previous case",
208+
true,
209+
30,
210+
third.Token,
211+
http.StatusBadRequest,
212+
map[string]interface{}{
213+
"error": "invalid_grant",
214+
"error_description": "Invalid Refresh Token",
215+
},
216+
},
217+
}
218+
219+
for _, c := range cases {
220+
ts.Run(c.desc, func() {
221+
ts.Config.Security.RefreshTokenRotationEnabled = c.refreshTokenRotationEnabled
222+
ts.Config.Security.RefreshTokenReuseInterval = c.reuseInterval
223+
var buffer bytes.Buffer
224+
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
225+
"refresh_token": c.refreshToken,
226+
}))
227+
req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=refresh_token", &buffer)
228+
req.Header.Set("Content-Type", "application/json")
229+
w := httptest.NewRecorder()
230+
ts.API.handler.ServeHTTP(w, req)
231+
assert.Equal(ts.T(), c.expectedCode, w.Code)
232+
233+
data := make(map[string]interface{})
234+
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
235+
for k, v := range c.expectedBody {
236+
require.Equal(ts.T(), v, data[k])
237+
}
238+
})
239+
}
240+
}
241+
153242
func (ts *TokenTestSuite) createBannedUser() *models.User {
154243
u, err := models.NewUser(ts.instanceID, "[email protected]", "password", ts.Config.JWT.Aud, nil)
155244
require.NoError(ts.T(), err, "Error creating test user model")

conf/configuration.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ type CaptchaConfiguration struct {
181181
type SecurityConfiguration struct {
182182
Captcha CaptchaConfiguration `json:"captcha"`
183183
RefreshTokenRotationEnabled bool `json:"refresh_token_rotation_enabled" split_words:"true" default:"true"`
184+
RefreshTokenReuseInterval int `json:"refresh_token_reuse_interval" split_words:"true"`
184185
UpdatePasswordRequireReauthentication bool `json:"update_password_require_reauthentication" split_words:"true"`
185186
}
186187

example.env

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,9 @@ GOTRUE_EXTERNAL_SAML_SIGNING_KEY=""
190190

191191
# Additional Security config
192192
GOTRUE_LOG_LEVEL="debug"
193-
GOTRUE_REFRESH_TOKEN_ROTATION_ENABLED="false"
193+
GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED="false"
194+
GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL="0"
195+
GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION="false"
194196
GOTRUE_OPERATOR_TOKEN="unused-operator-token"
195197
GOTRUE_RATE_LIMIT_HEADER="X-Forwarded-For"
196198
GOTRUE_RATE_LIMIT_EMAIL_SENT="100"

models/refresh_token.go

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package models
22

33
import (
4+
"database/sql"
45
"time"
56

67
"github.com/gobuffalo/pop/v5"
@@ -57,19 +58,33 @@ func GrantRefreshTokenSwap(tx *storage.Connection, user *User, token *RefreshTok
5758

5859
// RevokeTokenFamily revokes all refresh tokens that descended from the provided token.
5960
func RevokeTokenFamily(tx *storage.Connection, token *RefreshToken) error {
61+
tablename := (&pop.Model{Value: RefreshToken{}}).TableName()
6062
err := tx.RawQuery(`
6163
with recursive token_family as (
62-
select id, user_id, token, revoked, parent from refresh_tokens where parent = ?
64+
select id, user_id, token, revoked, parent from `+tablename+` where parent = ?
6365
union
64-
select r.id, r.user_id, r.token, r.revoked, r.parent from `+(&pop.Model{Value: RefreshToken{}}).TableName()+` r inner join token_family t on t.token = r.parent
66+
select r.id, r.user_id, r.token, r.revoked, r.parent from `+tablename+` r inner join token_family t on t.token = r.parent
6567
)
66-
update `+(&pop.Model{Value: RefreshToken{}}).TableName()+` r set revoked = true from token_family where token_family.id = r.id;`, token.Token).Exec()
68+
update `+tablename+` r set revoked = true from token_family where token_family.id = r.id;`, token.Token).Exec()
6769
if err != nil {
6870
return err
6971
}
7072
return nil
7173
}
7274

75+
// GetValidChildToken returns the child token of the token provided if the child is not revoked.
76+
func GetValidChildToken(tx *storage.Connection, token *RefreshToken) (*RefreshToken, error) {
77+
refreshToken := &RefreshToken{}
78+
err := tx.Q().Where("parent = ? and revoked = false", token.Token).First(refreshToken)
79+
if err != nil {
80+
if errors.Cause(err) == sql.ErrNoRows {
81+
return nil, RefreshTokenNotFoundError{}
82+
}
83+
return nil, err
84+
}
85+
return refreshToken, nil
86+
}
87+
7388
// Logout deletes all refresh tokens for a user.
7489
func Logout(tx *storage.Connection, instanceID uuid.UUID, id uuid.UUID) error {
7590
return tx.RawQuery("DELETE FROM "+(&pop.Model{Value: RefreshToken{}}).TableName()+" WHERE instance_id = ? AND user_id = ?", instanceID, id).Exec()

0 commit comments

Comments
 (0)