From 24266872f9159664551b354e3f1ae2f6f011755b Mon Sep 17 00:00:00 2001 From: Dan Milne Date: Thu, 11 Jun 2026 20:23:17 +1000 Subject: [PATCH] Revoke access tokens too on refresh-token reuse detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/models/oidc_refresh_token.rb | 14 +++++- .../oidc_refresh_token_controller_test.rb | 44 +++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/app/models/oidc_refresh_token.rb b/app/models/oidc_refresh_token.rb index de5b988..c881980 100644 --- a/app/models/oidc_refresh_token.rb +++ b/app/models/oidc_refresh_token.rb @@ -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 diff --git a/test/controllers/oidc_refresh_token_controller_test.rb b/test/controllers/oidc_refresh_token_controller_test.rb index 0381ae9..293255e 100644 --- a/test/controllers/oidc_refresh_token_controller_test.rb +++ b/test/controllers/oidc_refresh_token_controller_test.rb @@ -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,