Revoke access tokens too on refresh-token reuse detection
revoke_family! revoked only the refresh tokens in a rotation family. When reuse of a revoked refresh token was detected (a token-theft signal), the access tokens issued across that chain stayed valid at /userinfo until expiry — up to the access-token TTL — so an attacker holding a stolen access token kept access. revoke_family! now also revokes every access token referenced by the family's refresh tokens. Adds a regression test: rotate once, reuse the revoked token, and assert both the original and rotated-in access tokens are revoked. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -49,11 +49,21 @@ class OidcRefreshToken < ApplicationRecord
|
||||
update!(revoked_at: Time.current)
|
||||
end
|
||||
|
||||
# Revoke all refresh tokens in the same family (token rotation security)
|
||||
# Revoke all refresh tokens in the same family (token rotation security).
|
||||
# Also revoke every access token issued within the family: on a detected reuse
|
||||
# attack the stolen chain's access tokens must not remain usable at /userinfo
|
||||
# until they expire.
|
||||
def revoke_family!
|
||||
return unless token_family_id.present?
|
||||
|
||||
OidcRefreshToken.in_family(token_family_id).update_all(revoked_at: Time.current)
|
||||
now = Time.current
|
||||
family = OidcRefreshToken.in_family(token_family_id)
|
||||
access_token_ids = family.pluck(:oidc_access_token_id).compact.uniq
|
||||
|
||||
family.update_all(revoked_at: now)
|
||||
if access_token_ids.any?
|
||||
OidcAccessToken.where(id: access_token_ids, revoked_at: nil).update_all(revoked_at: now)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -213,6 +213,50 @@ class OidcRefreshTokenControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_equal family_id, new_refresh_token.token_family_id
|
||||
end
|
||||
|
||||
test "reusing a revoked refresh token revokes every access token in the family" do
|
||||
access_token = OidcAccessToken.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
scope: "openid profile email"
|
||||
)
|
||||
refresh_token = OidcRefreshToken.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
oidc_access_token: access_token,
|
||||
scope: "openid profile email"
|
||||
)
|
||||
family_id = refresh_token.token_family_id
|
||||
old_plaintext = refresh_token.token
|
||||
|
||||
# Rotate once: the old refresh token is revoked; a new access + refresh token
|
||||
# are issued into the same family.
|
||||
post "/oauth/token", params: {
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: old_plaintext,
|
||||
client_id: @application.client_id,
|
||||
client_secret: @client_secret
|
||||
}
|
||||
assert_response :success
|
||||
|
||||
new_refresh = OidcRefreshToken.in_family(family_id).where.not(id: refresh_token.id).first
|
||||
new_access_token = new_refresh.oidc_access_token
|
||||
refute new_access_token.reload.revoked?, "rotated-in access token should start active"
|
||||
|
||||
# Reuse the OLD (now revoked) refresh token -> rotation-attack detection.
|
||||
post "/oauth/token", params: {
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: old_plaintext,
|
||||
client_id: @application.client_id,
|
||||
client_secret: @client_secret
|
||||
}
|
||||
assert_response :bad_request
|
||||
|
||||
# Both the original and the rotated-in access token must now be revoked, so a
|
||||
# stolen access token from anywhere in the chain stops working at /userinfo.
|
||||
assert access_token.reload.revoked?, "original access token should be revoked"
|
||||
assert new_access_token.reload.revoked?, "rotated-in access token should be revoked"
|
||||
end
|
||||
|
||||
test "userinfo endpoint works with hashed access token" do
|
||||
access_token = OidcAccessToken.create!(
|
||||
application: @application,
|
||||
|
||||
Reference in New Issue
Block a user