Harden OIDC token endpoints from security review
Fixes from review of the device flow / introspection / DCR work:
Introspection over-disclosure (RFC 7662):
- Restrict introspection to authorized callers — the client a token was issued
to, or a resource server registered (resource_identifiers) to serve the token's
bound RFC 8707 audience. Unauthorized callers get {active:false}, disclosing
nothing, instead of any confidential client reading any token.
- Scope-gate disclosed claims: username only with the email scope, groups only
with the groups scope (mirrors userinfo). ADR 0005.
Tokens minted for revoked users:
- Re-check application.user_allowed?(user) at mint time in the device, refresh,
and authorization-code grants (covers app-active, user-active, group
membership). A user deactivated or removed from the allowed group between
approval and the token request is refused with access_denied. The refresh
check runs before rotation so a denied refresh has no side effects.
Device authorization hardening:
- Require confidential clients to authenticate, and require PKCE (code_challenge)
up front for clients that require it, so an intercepted device_code plus a
known public client_id cannot redeem tokens.
- Merge (not overwrite) the shared consent record on device approval.
Tests: new oidc_introspection_test and oidc_mint_authorization_test; existing
PKCE/claims tests updated to grant app access (they created ungrouped apps and
relied on the token endpoint not checking authorization). Full suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F7cwhwDJp3MJJDoNPVE6zq
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
2defa26a87
commit
017dfdff0e
@@ -0,0 +1,114 @@
|
||||
require "test_helper"
|
||||
|
||||
# Tokens must not be minted for a user who has lost access (deactivated, or removed
|
||||
# from the application's allowed group) between authorization and the token request.
|
||||
# Every grant re-checks Application#user_allowed? at mint time.
|
||||
class OidcMintAuthorizationTest < ActionDispatch::IntegrationTest
|
||||
DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code".freeze
|
||||
|
||||
def setup
|
||||
@group = Group.create!(name: "mint-authz-testers", description: "test")
|
||||
@user = User.create!(email_address: "mint_authz@example.com", password: "password123")
|
||||
@user.groups << @group
|
||||
|
||||
@secret = "mint-authz-secret-value-abcdefghij"
|
||||
@application = Application.create!(name: "Mint Authz App", slug: "mint-authz-app", app_type: "oidc",
|
||||
client_secret: @secret, active: true, require_pkce: false,
|
||||
redirect_uris: ["https://app.example.com/cb"].to_json)
|
||||
@application.allowed_groups << @group
|
||||
|
||||
OidcUserConsent.create!(user: @user, application: @application, scopes_granted: "openid", granted_at: Time.current)
|
||||
end
|
||||
|
||||
def teardown
|
||||
OidcRefreshToken.where(application: @application).delete_all
|
||||
OidcAccessToken.where(application: @application).delete_all
|
||||
OidcDeviceCode.where(application: @application).delete_all
|
||||
OidcAuthorizationCode.where(application: @application).delete_all
|
||||
OidcUserConsent.where(application: @application).delete_all
|
||||
end
|
||||
|
||||
# --- Device grant ----------------------------------------------------------
|
||||
|
||||
test "device grant issues tokens for a still-allowed user" do
|
||||
poll(approved_device_code)
|
||||
assert_response :success
|
||||
assert JSON.parse(@response.body)["access_token"].present?
|
||||
end
|
||||
|
||||
test "device grant refuses a user removed from the allowed group after approval" do
|
||||
dc = approved_device_code
|
||||
revoke_group!
|
||||
poll(dc)
|
||||
assert_access_denied
|
||||
end
|
||||
|
||||
test "device grant refuses a deactivated user after approval" do
|
||||
dc = approved_device_code
|
||||
@user.disabled!
|
||||
poll(dc)
|
||||
assert_access_denied
|
||||
end
|
||||
|
||||
# --- Authorization code grant ---------------------------------------------
|
||||
|
||||
test "authorization_code grant refuses a user removed from the allowed group" do
|
||||
code = OidcAuthorizationCode.create!(application: @application, user: @user,
|
||||
redirect_uri: "https://app.example.com/cb", scope: "openid", auth_time: Time.current.to_i, acr: "1")
|
||||
revoke_group!
|
||||
post "/oauth/token", params: {grant_type: "authorization_code", code: code.plaintext_code,
|
||||
redirect_uri: "https://app.example.com/cb", client_id: @application.client_id, client_secret: @secret}
|
||||
assert_access_denied
|
||||
end
|
||||
|
||||
# --- Refresh grant ---------------------------------------------------------
|
||||
|
||||
test "refresh_token grant refuses a deactivated user" do
|
||||
refresh = issue_refresh_token
|
||||
@user.disabled!
|
||||
refresh_with(refresh)
|
||||
assert_access_denied
|
||||
end
|
||||
|
||||
test "refresh_token grant refuses a removed user and leaves the token intact" do
|
||||
refresh = issue_refresh_token
|
||||
revoke_group!
|
||||
refresh_with(refresh)
|
||||
assert_access_denied
|
||||
assert_not refresh.reload.revoked?, "a denied refresh must not rotate/revoke the token"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def approved_device_code
|
||||
dc = OidcDeviceCode.create!(application: @application, scope: "openid")
|
||||
dc.approve!(user: @user, acr: "1", auth_time: Time.current.to_i)
|
||||
dc
|
||||
end
|
||||
|
||||
def issue_refresh_token
|
||||
access = OidcAccessToken.create!(application: @application, user: @user, scope: "openid")
|
||||
OidcRefreshToken.create!(application: @application, user: @user, oidc_access_token: access,
|
||||
scope: "openid", auth_time: Time.current.to_i, acr: "1")
|
||||
end
|
||||
|
||||
def revoke_group!
|
||||
UserGroup.where(user: @user, group: @group).delete_all
|
||||
@user.reload
|
||||
end
|
||||
|
||||
def poll(dc)
|
||||
post "/oauth/token", params: {grant_type: DEVICE_GRANT, device_code: dc.plaintext_device_code,
|
||||
client_id: @application.client_id, client_secret: @secret}
|
||||
end
|
||||
|
||||
def refresh_with(refresh)
|
||||
post "/oauth/token", params: {grant_type: "refresh_token", refresh_token: refresh.token,
|
||||
client_id: @application.client_id, client_secret: @secret}
|
||||
end
|
||||
|
||||
def assert_access_denied
|
||||
assert_response :bad_request
|
||||
assert_equal "access_denied", JSON.parse(@response.body)["error"]
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user