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>
283 lines
8.4 KiB
Ruby
283 lines
8.4 KiB
Ruby
require "test_helper"
|
|
|
|
class OidcRefreshTokenControllerTest < ActionDispatch::IntegrationTest
|
|
setup do
|
|
@user = users(:alice)
|
|
@application = applications(:kavita_app)
|
|
# Store a known client secret for testing
|
|
@client_secret = SecureRandom.urlsafe_base64(48)
|
|
@application.client_secret = @client_secret
|
|
@application.save!
|
|
end
|
|
|
|
test "token endpoint returns refresh_token with authorization_code grant" do
|
|
# Create an authorization code
|
|
auth_code = OidcAuthorizationCode.create!(
|
|
application: @application,
|
|
user: @user,
|
|
redirect_uri: @application.parsed_redirect_uris.first,
|
|
scope: "openid profile email",
|
|
expires_at: 10.minutes.from_now
|
|
)
|
|
|
|
# Exchange authorization code for tokens
|
|
post "/oauth/token", params: {
|
|
grant_type: "authorization_code",
|
|
code: auth_code.plaintext_code,
|
|
redirect_uri: @application.parsed_redirect_uris.first,
|
|
client_id: @application.client_id,
|
|
client_secret: @client_secret
|
|
}
|
|
|
|
assert_response :success
|
|
json = JSON.parse(response.body)
|
|
|
|
assert json["access_token"].present?
|
|
assert json["id_token"].present?
|
|
assert json["refresh_token"].present?
|
|
assert_equal "Bearer", json["token_type"]
|
|
assert_equal 3600, json["expires_in"]
|
|
end
|
|
|
|
test "refresh_token grant exchanges refresh token for new tokens" do
|
|
# Create access and refresh tokens
|
|
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"
|
|
)
|
|
|
|
# Store the plaintext refresh token (available only during creation)
|
|
plaintext_refresh_token = refresh_token.token
|
|
|
|
# Use refresh token to get new tokens
|
|
post "/oauth/token", params: {
|
|
grant_type: "refresh_token",
|
|
refresh_token: plaintext_refresh_token,
|
|
client_id: @application.client_id,
|
|
client_secret: @client_secret
|
|
}
|
|
|
|
assert_response :success
|
|
json = JSON.parse(response.body)
|
|
|
|
assert json["access_token"].present?
|
|
assert json["id_token"].present?
|
|
assert json["refresh_token"].present?
|
|
assert_equal "Bearer", json["token_type"]
|
|
|
|
# Old refresh token should be revoked
|
|
assert refresh_token.reload.revoked?
|
|
end
|
|
|
|
test "refresh_token grant fails with expired refresh token" 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",
|
|
expires_at: 1.hour.ago # Expired
|
|
)
|
|
|
|
plaintext_refresh_token = refresh_token.token
|
|
|
|
post "/oauth/token", params: {
|
|
grant_type: "refresh_token",
|
|
refresh_token: plaintext_refresh_token,
|
|
client_id: @application.client_id,
|
|
client_secret: @client_secret
|
|
}
|
|
|
|
assert_response :bad_request
|
|
json = JSON.parse(response.body)
|
|
assert_equal "invalid_grant", json["error"]
|
|
end
|
|
|
|
test "refresh_token grant fails with revoked refresh token" 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"
|
|
)
|
|
|
|
plaintext_refresh_token = refresh_token.token
|
|
refresh_token.revoke!
|
|
|
|
post "/oauth/token", params: {
|
|
grant_type: "refresh_token",
|
|
refresh_token: plaintext_refresh_token,
|
|
client_id: @application.client_id,
|
|
client_secret: @client_secret
|
|
}
|
|
|
|
assert_response :bad_request
|
|
json = JSON.parse(response.body)
|
|
assert_equal "invalid_grant", json["error"]
|
|
end
|
|
|
|
test "token revocation endpoint revokes access tokens" do
|
|
access_token = OidcAccessToken.create!(
|
|
application: @application,
|
|
user: @user,
|
|
scope: "openid profile email"
|
|
)
|
|
|
|
plaintext_access_token = access_token.plaintext_token
|
|
|
|
post "/oauth/revoke", params: {
|
|
token: plaintext_access_token,
|
|
token_type_hint: "access_token",
|
|
client_id: @application.client_id,
|
|
client_secret: @client_secret
|
|
}
|
|
|
|
assert_response :success
|
|
assert access_token.reload.revoked?
|
|
end
|
|
|
|
test "token revocation endpoint revokes refresh tokens" 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"
|
|
)
|
|
|
|
plaintext_refresh_token = refresh_token.token
|
|
|
|
post "/oauth/revoke", params: {
|
|
token: plaintext_refresh_token,
|
|
token_type_hint: "refresh_token",
|
|
client_id: @application.client_id,
|
|
client_secret: @client_secret
|
|
}
|
|
|
|
assert_response :success
|
|
assert refresh_token.reload.revoked?
|
|
end
|
|
|
|
test "token rotation: new refresh token has same family id" do
|
|
access_token = OidcAccessToken.create!(
|
|
application: @application,
|
|
user: @user,
|
|
scope: "openid profile email"
|
|
)
|
|
|
|
old_refresh_token = OidcRefreshToken.create!(
|
|
application: @application,
|
|
user: @user,
|
|
oidc_access_token: access_token,
|
|
scope: "openid profile email"
|
|
)
|
|
|
|
family_id = old_refresh_token.token_family_id
|
|
plaintext_refresh_token = old_refresh_token.token
|
|
|
|
post "/oauth/token", params: {
|
|
grant_type: "refresh_token",
|
|
refresh_token: plaintext_refresh_token,
|
|
client_id: @application.client_id,
|
|
client_secret: @client_secret
|
|
}
|
|
|
|
assert_response :success
|
|
|
|
# Find the new refresh token
|
|
new_refresh_token = OidcRefreshToken.active.where(user: @user, application: @application).last
|
|
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,
|
|
user: @user,
|
|
scope: "openid profile email"
|
|
)
|
|
|
|
plaintext_token = access_token.plaintext_token
|
|
|
|
get "/oauth/userinfo", headers: {
|
|
"Authorization" => "Bearer #{plaintext_token}"
|
|
}
|
|
|
|
assert_response :success
|
|
json = JSON.parse(response.body)
|
|
|
|
# Should return pairwise SID from consent (alice has consent for kavita_app in fixtures)
|
|
consent = OidcUserConsent.find_by(user: @user, application: @application)
|
|
expected_sub = consent&.sid || @user.id.to_s
|
|
assert_equal expected_sub, json["sub"]
|
|
assert_equal @user.email_address, json["email"]
|
|
end
|
|
end
|