Files
clinch/test/controllers/oidc_introspection_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

137 lines
5.3 KiB
Ruby

require "test_helper"
# RFC 7662 token introspection: authorization (who may introspect which token)
# and claim scope-gating (only disclose identity claims the token was granted).
class OidcIntrospectionTest < ActionDispatch::IntegrationTest
RESOURCE = "https://api.example.com".freeze
def setup
@group = Group.create!(name: "introspection-testers", description: "test")
@user = User.create!(email_address: "introspect@example.com", password: "password123")
@user.groups << @group
# The OAuth client the tokens are issued to (a public CLI-style client).
@client = Application.create!(name: "Introspect Client", slug: "introspect-client",
app_type: "oidc", is_public_client: true, active: true)
# The resource server that serves RESOURCE and is allowed to introspect
# tokens bound to it.
@rs_secret = "rs-secret-value-abcdefghijklmnop"
@rs = Application.create!(name: "Introspect RS", slug: "introspect-rs", app_type: "oidc",
client_secret: @rs_secret, active: true, resource_identifiers: [RESOURCE].to_json)
# A confidential client that neither issued the token nor serves its resource.
@other_secret = "other-secret-value-abcdefghijklmn"
@other = Application.create!(name: "Introspect Other", slug: "introspect-other",
app_type: "oidc", client_secret: @other_secret, active: true)
end
def teardown
[@client, @rs, @other].each do |app|
OidcAccessToken.where(application: app).delete_all
OidcUserConsent.where(application: app).delete_all
end
end
# --- Authorization ---------------------------------------------------------
test "a resource server may introspect a token bound to a resource it serves" do
token = issue(scope: "openid groups email", resource: RESOURCE)
body = introspect(token, @rs.client_id, @rs_secret)
assert_equal true, body["active"]
assert_equal @client.client_id, body["client_id"]
assert_equal RESOURCE, body["aud"]
end
test "a client may introspect its own token" do
token = OidcAccessToken.create!(application: @rs, user: @user, scope: "openid")
body = introspect(token, @rs.client_id, @rs_secret)
assert_equal true, body["active"]
assert_equal @rs.client_id, body["aud"]
end
test "a caller cannot introspect a token bound to a resource it does not serve" do
token = issue(scope: "openid groups email", resource: RESOURCE)
body = introspect(token, @other.client_id, @other_secret)
assert_equal false, body["active"], "unauthorized caller must learn nothing"
assert_nil body["username"]
assert_nil body["groups"]
end
test "a caller cannot introspect an unbound token it did not issue" do
token = issue(scope: "openid groups", resource: nil)
body = introspect(token, @rs.client_id, @rs_secret)
assert_equal false, body["active"]
end
# --- Claim scope-gating ----------------------------------------------------
test "omits email and groups when the token lacks those scopes" do
token = issue(scope: "openid", resource: RESOURCE)
body = introspect(token, @rs.client_id, @rs_secret)
assert_equal true, body["active"]
assert_not body.key?("username"), "email must not leak without the email scope"
assert_not body.key?("groups"), "groups must not leak without the groups scope"
end
test "includes email only with the email scope" do
token = issue(scope: "openid email", resource: RESOURCE)
body = introspect(token, @rs.client_id, @rs_secret)
assert_equal @user.email_address, body["username"]
assert_not body.key?("groups")
end
test "includes groups only with the groups scope" do
token = issue(scope: "openid groups", resource: RESOURCE)
body = introspect(token, @rs.client_id, @rs_secret)
assert_includes body["groups"], @group.name
assert_not body.key?("username")
end
# --- Token / caller validity ----------------------------------------------
test "reports inactive for a revoked token even to an authorized caller" do
token = issue(scope: "openid groups", resource: RESOURCE)
token.revoke!
assert_equal false, introspect(token, @rs.client_id, @rs_secret)["active"]
end
test "requires valid caller credentials" do
token = issue(scope: "openid", resource: RESOURCE)
post "/oauth/introspect", params: {token: token.plaintext_token, client_id: @rs.client_id, client_secret: "wrong"}
assert_response :unauthorized
end
test "rejects a public (non-confidential) caller" do
token = issue(scope: "openid", resource: RESOURCE)
post "/oauth/introspect", params: {token: token.plaintext_token, client_id: @client.client_id}
assert_response :unauthorized
end
test "requires a token parameter" do
post "/oauth/introspect", params: {client_id: @rs.client_id, client_secret: @rs_secret}
assert_response :bad_request
assert_equal "invalid_request", JSON.parse(@response.body)["error"]
end
private
def issue(scope:, resource:)
OidcAccessToken.create!(application: @client, user: @user, scope: scope, resource: resource)
end
def introspect(token, client_id, secret)
plaintext = token.respond_to?(:plaintext_token) ? token.plaintext_token : token
post "/oauth/introspect", params: {token: plaintext, client_id: client_id, client_secret: secret}
assert_response :success
JSON.parse(@response.body)
end
end