fix: a device revoke cannot lose to a concurrent refresh
The revoke now reads the device's jti with UPDATE ... RETURNING under the row lock, and the refresh's hand-over (classes.FollowDeviceJTI) reports a session revoked meanwhile, so the refresh answers the blacklisted-token 401 without a token. A hand-over error fails the refresh closed instead of leaving the session on a stale jti.
This commit is contained in:
@@ -132,13 +132,41 @@ func DeviceDisplayName(stored *string, userAgent string) string {
|
||||
// HandOverDeviceJTI moves a used device session from the jti of a session
|
||||
// token to the jti of the token that refreshed it, and stamps its
|
||||
// last_activity_at, so revoking the device still reaches the token the
|
||||
// device holds now. POST /refresh calls it after a successful refresh;
|
||||
// device holds now. It is FollowDeviceJTI without the revoked report;
|
||||
// empty values do nothing.
|
||||
func HandOverDeviceJTI(ctx context.Context, db *gorm.DB, oldJTI, newJTI string) error {
|
||||
if db == nil || oldJTI == "" || newJTI == "" || oldJTI == newJTI {
|
||||
return nil
|
||||
}
|
||||
return db.WithContext(ctx).Exec(`UPDATE golem15_user_device_auth_sessions
|
||||
SET jwt_jti = ?, last_activity_at = NOW(), updated_at = NOW()
|
||||
WHERE jwt_jti = ? AND status = 'used'`, newJTI, oldJTI).Error
|
||||
_, err := FollowDeviceJTI(ctx, db, oldJTI, newJTI)
|
||||
return err
|
||||
}
|
||||
|
||||
// FollowDeviceJTI moves a used device session from oldJTI, the jti of the
|
||||
// session token a refresh presented, to newJTI, the jti of the token it
|
||||
// minted, and stamps last_activity_at. It reports revoked when no used
|
||||
// session moved but a session with oldJTI exists in another status: the
|
||||
// device was revoked after the refresh passed the blacklist check, so the
|
||||
// caller must refuse the refresh and drop the new token. The move is one
|
||||
// guarded UPDATE that takes the session's row lock, as the revoke's
|
||||
// UPDATE … RETURNING jwt_jti does (controllers.DeviceDestroy): a revoke
|
||||
// that runs after the move blacklists newJTI, and a move that runs after
|
||||
// the revoke matches nothing and reports revoked. POST /refresh calls it
|
||||
// and fails closed on an error. Empty or equal values do nothing.
|
||||
func FollowDeviceJTI(ctx context.Context, db *gorm.DB, oldJTI, newJTI string) (revoked bool, err error) {
|
||||
if db == nil || oldJTI == "" || newJTI == "" || oldJTI == newJTI {
|
||||
return false, nil
|
||||
}
|
||||
res := db.WithContext(ctx).Exec(`UPDATE golem15_user_device_auth_sessions
|
||||
SET jwt_jti = ?, last_activity_at = NOW(), updated_at = NOW()
|
||||
WHERE jwt_jti = ? AND status = 'used'`, newJTI, oldJTI)
|
||||
if res.Error != nil {
|
||||
return false, res.Error
|
||||
}
|
||||
if res.RowsAffected > 0 {
|
||||
return false, nil
|
||||
}
|
||||
var stale int64
|
||||
if err := db.WithContext(ctx).Raw(`SELECT COUNT(*) FROM golem15_user_device_auth_sessions
|
||||
WHERE jwt_jti = ? AND status <> 'used'`, oldJTI).Scan(&stale).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return stale > 0, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user