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:
Dan Milne
2026-07-19 13:19:41 +10:00
co-authored by Claude Opus 4.8
parent 2defa26a87
commit 017dfdff0e
14 changed files with 533 additions and 67 deletions
@@ -0,0 +1,8 @@
class AddResourceIdentifiersToApplications < ActiveRecord::Migration[8.1]
# The RFC 8707 resource identifier(s) this application serves as a resource
# server. Used to authorize RFC 7662 introspection: a caller may only introspect
# tokens bound to a resource it serves (or tokens issued to itself).
def change
add_column :applications, :resource_identifiers, :text
end
end
Generated
+2 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_07_19_000003) do
ActiveRecord::Schema[8.1].define(version: 2026_07_19_000004) do
create_table "active_storage_attachments", force: :cascade do |t|
t.bigint "blob_id", null: false
t.datetime "created_at", null: false
@@ -96,6 +96,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_07_19_000003) do
t.text "redirect_uris"
t.integer "refresh_token_ttl", default: 2592000
t.boolean "require_pkce", default: true, null: false
t.text "resource_identifiers"
t.boolean "skip_consent", default: false, null: false
t.string "slug", null: false
t.datetime "updated_at", null: false
+13 -4
View File
@@ -34,18 +34,27 @@ end
# Confidential client that resource servers (e.g. c2a2) use to authenticate to
# the introspection endpoint. The secret is only shown once, on creation.
#
# resource_identifiers declares the RFC 8707 resource URI(s) this server answers
# for. Introspection is authorized against it: c2a2 may only introspect tokens
# whose bound audience is one of these. The CLI/agent must therefore request its
# token with resource=<C2A2_RESOURCE>. Set C2A2_RESOURCE to c2a2's real URL.
unless Application.exists?(client_id: "c2a2-introspection")
secret = SecureRandom.urlsafe_base64(48)
c2a2_resource = ENV["C2A2_RESOURCE"].presence || "https://c2a2.example.com"
Application.create!(
name: "c2a2 (introspection caller)",
slug: "c2a2-introspection",
client_id: "c2a2-introspection",
client_secret: secret,
app_type: "oidc",
active: true
active: true,
resource_identifiers: [c2a2_resource].to_json
)
puts "Seeded 'c2a2-introspection' confidential client:"
puts " client_id: c2a2-introspection"
puts " client_secret: #{secret}"
puts " Store these in c2a2 now — the secret is hashed and cannot be recovered."
puts " client_id: c2a2-introspection"
puts " client_secret: #{secret}"
puts " resource_identifier: #{c2a2_resource}"
puts " Store the secret in c2a2 now — it is hashed and cannot be recovered."
puts " The CLI must request tokens with resource=#{c2a2_resource} for c2a2 to introspect them."
end