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
@@ -24,8 +24,10 @@ class OidcResourceIndicatorsTest < ActionDispatch::IntegrationTest
|
||||
@web.allowed_groups << @group
|
||||
|
||||
@resource_secret = "resource-server-secret-value-abcdefghij"
|
||||
# The resource server is registered to serve RESOURCE, so it is authorized to
|
||||
# introspect tokens bound to it (see OidcController#caller_may_introspect?).
|
||||
@resource = Application.create!(name: "Resource RS", slug: "resource-rs2", app_type: "oidc",
|
||||
client_secret: @resource_secret, active: true)
|
||||
client_secret: @resource_secret, active: true, resource_identifiers: [RESOURCE].to_json)
|
||||
end
|
||||
|
||||
def teardown
|
||||
@@ -84,7 +86,10 @@ class OidcResourceIndicatorsTest < ActionDispatch::IntegrationTest
|
||||
# --- Device flow -----------------------------------------------------------
|
||||
|
||||
test "device flow binds the resource to the issued token" do
|
||||
post "/oauth/device_authorization", params: {client_id: @cli.client_id, scope: "openid", resource: RESOURCE}
|
||||
post "/oauth/device_authorization", params: {
|
||||
client_id: @cli.client_id, scope: "openid", resource: RESOURCE,
|
||||
code_challenge: PKCE_CHALLENGE, code_challenge_method: "S256"
|
||||
}
|
||||
assert_response :success
|
||||
auth = JSON.parse(@response.body)
|
||||
|
||||
@@ -94,7 +99,7 @@ class OidcResourceIndicatorsTest < ActionDispatch::IntegrationTest
|
||||
OidcUserConsent.create!(user: @user, application: @cli, scopes_granted: "openid", granted_at: Time.current)
|
||||
dc.approve!(user: @user, acr: "1", auth_time: Time.current.to_i)
|
||||
|
||||
post "/oauth/token", params: {grant_type: DEVICE_GRANT, device_code: auth["device_code"], client_id: @cli.client_id}
|
||||
post "/oauth/token", params: {grant_type: DEVICE_GRANT, device_code: auth["device_code"], client_id: @cli.client_id, code_verifier: PKCE_VERIFIER}
|
||||
assert_response :success
|
||||
access = JSON.parse(@response.body)["access_token"]
|
||||
|
||||
@@ -102,7 +107,10 @@ class OidcResourceIndicatorsTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "device_authorization rejects an invalid resource" do
|
||||
post "/oauth/device_authorization", params: {client_id: @cli.client_id, resource: "not-an-absolute-uri"}
|
||||
post "/oauth/device_authorization", params: {
|
||||
client_id: @cli.client_id, resource: "not-an-absolute-uri",
|
||||
code_challenge: PKCE_CHALLENGE, code_challenge_method: "S256"
|
||||
}
|
||||
assert_response :bad_request
|
||||
assert_equal "invalid_target", JSON.parse(@response.body)["error"]
|
||||
end
|
||||
@@ -110,8 +118,10 @@ class OidcResourceIndicatorsTest < ActionDispatch::IntegrationTest
|
||||
# --- Fallback --------------------------------------------------------------
|
||||
|
||||
test "introspection aud falls back to the client when no resource was bound" do
|
||||
token = OidcAccessToken.create!(application: @cli, user: @user, scope: "openid")
|
||||
assert_equal @cli.client_id, introspect(token.plaintext_token)["aud"]
|
||||
# A confidential client introspecting its own unbound token (no RFC 8707
|
||||
# resource) sees aud fall back to the client_id.
|
||||
token = OidcAccessToken.create!(application: @resource, user: @user, scope: "openid")
|
||||
assert_equal @resource.client_id, introspect(token.plaintext_token)["aud"]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
Reference in New Issue
Block a user