Files
clinch/test/controllers/oidc_mint_authorization_test.rb
T
Dan MilneandClaude Opus 4.8 017dfdff0e 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
2026-07-19 13:19:41 +10:00

115 lines
4.2 KiB
Ruby

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