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
137 lines
5.3 KiB
Ruby
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
|