Add RFC 8707 Resource Indicators (audience binding)

Clients can name a target resource server via the `resource` parameter at the
authorization and device-authorization endpoints. Clinch binds it to the issued
token as its audience and reports it at introspection as `aud`, so a token minted
for one API cannot be replayed against another that also trusts clinch.

- `resource` is validated syntax-only (absolute URI, no fragment) — pass-through,
  no resource registry; the resource server enforces the audience on introspection
- threaded from authorize / device_authorization onto the auth/device code, then
  the access and refresh tokens, and carried across refresh rotation
- invalid values are rejected with error=invalid_target
- introspection `aud` is the bound resource, falling back to the client_id
- ADR 0004 records the pass-through-vs-registry decision

Completes the clinch-side OAuth surface for MCP connectors (with DCR +
introspection); Protected Resource Metadata (RFC 9728) lives on the resource server.

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 12:41:10 +10:00
co-authored by Claude Opus 4.8
parent 7149b98b7b
commit 2defa26a87
6 changed files with 242 additions and 8 deletions
+50 -7
View File
@@ -102,10 +102,18 @@ class OidcController < ApplicationController
return return
end end
# RFC 8707 Resource Indicator (optional): bind the eventual token to a target.
resource = params[:resource].presence
if resource && !valid_resource_indicator?(resource)
render json: {error: "invalid_target", error_description: "resource must be an absolute URI without a fragment"}, status: :bad_request
return
end
device_code = OidcDeviceCode.create!( device_code = OidcDeviceCode.create!(
application: application, application: application,
scope: requested_scope, scope: requested_scope,
nonce: params[:nonce].presence, nonce: params[:nonce].presence,
resource: resource,
code_challenge: code_challenge, code_challenge: code_challenge,
code_challenge_method: code_challenge.present? ? (code_challenge_method || "S256") : nil code_challenge_method: code_challenge.present? ? (code_challenge_method || "S256") : nil
) )
@@ -136,6 +144,7 @@ class OidcController < ApplicationController
response_type = params[:response_type] response_type = params[:response_type]
code_challenge = params[:code_challenge] code_challenge = params[:code_challenge]
code_challenge_method = params[:code_challenge_method] || "S256" code_challenge_method = params[:code_challenge_method] || "S256"
resource = params[:resource] # RFC 8707 Resource Indicator (target audience)
# ============================================================================ # ============================================================================
# client_id and redirect_uri are already validated (see before_actions). # client_id and redirect_uri are already validated (see before_actions).
@@ -165,6 +174,16 @@ class OidcController < ApplicationController
return return
end end
# RFC 8707 §2: if a resource indicator is supplied it must be a valid target,
# otherwise the request is rejected with error=invalid_target.
if resource.present? && !valid_resource_indicator?(resource)
error_uri = "#{redirect_uri}?error=invalid_target"
error_uri += "&error_description=#{CGI.escape("resource must be an absolute URI without a fragment")}"
error_uri += "&state=#{CGI.escape(state)}" if state.present?
redirect_to error_uri, allow_other_host: true
return
end
# Validate PKCE parameters if present (now we can safely redirect with error) # Validate PKCE parameters if present (now we can safely redirect with error)
if code_challenge.present? if code_challenge.present?
unless code_challenge_method == "S256" unless code_challenge_method == "S256"
@@ -252,6 +271,7 @@ class OidcController < ApplicationController
scope: scope, scope: scope,
code_challenge: code_challenge, code_challenge: code_challenge,
code_challenge_method: code_challenge_method, code_challenge_method: code_challenge_method,
resource: resource,
claims_requests: parsed_claims&.to_json claims_requests: parsed_claims&.to_json
} }
# Store the current URL (with all OAuth params) for redirect after authentication # Store the current URL (with all OAuth params) for redirect after authentication
@@ -342,6 +362,7 @@ class OidcController < ApplicationController
nonce: nonce, nonce: nonce,
code_challenge: code_challenge, code_challenge: code_challenge,
code_challenge_method: code_challenge_method, code_challenge_method: code_challenge_method,
resource: resource,
claims_requests: parsed_claims || {}, claims_requests: parsed_claims || {},
auth_time: Current.session.created_at.to_i, auth_time: Current.session.created_at.to_i,
acr: Current.session.acr, acr: Current.session.acr,
@@ -367,6 +388,7 @@ class OidcController < ApplicationController
nonce: nonce, nonce: nonce,
code_challenge: code_challenge, code_challenge: code_challenge,
code_challenge_method: code_challenge_method, code_challenge_method: code_challenge_method,
resource: resource,
claims_requests: parsed_claims || {}, claims_requests: parsed_claims || {},
auth_time: Current.session.created_at.to_i, auth_time: Current.session.created_at.to_i,
acr: Current.session.acr, acr: Current.session.acr,
@@ -389,6 +411,7 @@ class OidcController < ApplicationController
scope: scope, scope: scope,
code_challenge: code_challenge, code_challenge: code_challenge,
code_challenge_method: code_challenge_method, code_challenge_method: code_challenge_method,
resource: resource,
claims_requests: parsed_claims&.to_json claims_requests: parsed_claims&.to_json
} }
@@ -474,6 +497,7 @@ class OidcController < ApplicationController
nonce: oauth_params["nonce"], nonce: oauth_params["nonce"],
code_challenge: oauth_params["code_challenge"], code_challenge: oauth_params["code_challenge"],
code_challenge_method: oauth_params["code_challenge_method"], code_challenge_method: oauth_params["code_challenge_method"],
resource: oauth_params["resource"],
claims_requests: parsed_claims, claims_requests: parsed_claims,
auth_time: Current.session.created_at.to_i, auth_time: Current.session.created_at.to_i,
acr: Current.session.acr, acr: Current.session.acr,
@@ -603,7 +627,8 @@ class OidcController < ApplicationController
access_token_record = OidcAccessToken.create!( access_token_record = OidcAccessToken.create!(
application: application, application: application,
user: user, user: user,
scope: granted_scope scope: granted_scope,
resource: device_code.resource
) )
refresh_token_record = OidcRefreshToken.create!( refresh_token_record = OidcRefreshToken.create!(
@@ -612,7 +637,8 @@ class OidcController < ApplicationController
oidc_access_token: access_token_record, oidc_access_token: access_token_record,
scope: granted_scope, scope: granted_scope,
auth_time: device_code.auth_time, auth_time: device_code.auth_time,
acr: device_code.acr acr: device_code.acr,
resource: device_code.resource
) )
id_token = OidcJwtService.generate_id_token( id_token = OidcJwtService.generate_id_token(
@@ -747,7 +773,8 @@ class OidcController < ApplicationController
application: application, application: application,
user: user, user: user,
scope: auth_code.scope, scope: auth_code.scope,
oidc_authorization_code: auth_code oidc_authorization_code: auth_code,
resource: auth_code.resource
) )
# Generate refresh token (opaque, with hashing) # Generate refresh token (opaque, with hashing)
@@ -758,7 +785,8 @@ class OidcController < ApplicationController
oidc_authorization_code: auth_code, oidc_authorization_code: auth_code,
scope: auth_code.scope, scope: auth_code.scope,
auth_time: auth_code.auth_time, auth_time: auth_code.auth_time,
acr: auth_code.acr acr: auth_code.acr,
resource: auth_code.resource
) )
# Find user consent for this application # Find user consent for this application
@@ -888,7 +916,8 @@ class OidcController < ApplicationController
application: application, application: application,
user: user, user: user,
scope: refresh_token_record.scope, scope: refresh_token_record.scope,
oidc_authorization_code: issuing_auth_code oidc_authorization_code: issuing_auth_code,
resource: refresh_token_record.resource
) )
# Generate new refresh token (token rotation) # Generate new refresh token (token rotation)
@@ -900,7 +929,8 @@ class OidcController < ApplicationController
scope: refresh_token_record.scope, scope: refresh_token_record.scope,
token_family_id: refresh_token_record.token_family_id, # Keep same family for rotation tracking token_family_id: refresh_token_record.token_family_id, # Keep same family for rotation tracking
auth_time: refresh_token_record.auth_time, # Carry over original auth_time auth_time: refresh_token_record.auth_time, # Carry over original auth_time
acr: refresh_token_record.acr # Carry over original acr acr: refresh_token_record.acr, # Carry over original acr
resource: refresh_token_record.resource # Carry the bound audience across rotation
) )
# Find user consent for this application # Find user consent for this application
@@ -1101,7 +1131,9 @@ class OidcController < ApplicationController
exp: access_token.expires_at.to_i, exp: access_token.expires_at.to_i,
iat: access_token.created_at.to_i, iat: access_token.created_at.to_i,
sub: consent&.sid || user.id.to_s, sub: consent&.sid || user.id.to_s,
aud: application.client_id, # RFC 8707: the resource the token was bound to (falls back to the client
# when no resource indicator was used at authorization time).
aud: access_token.resource.presence || application.client_id,
username: user.email_address, username: user.email_address,
groups: user.groups.pluck(:name) groups: user.groups.pluck(:name)
} }
@@ -1333,6 +1365,17 @@ class OidcController < ApplicationController
{valid: true} {valid: true}
end end
# RFC 8707 §2: a resource indicator must be an absolute URI and MUST NOT
# include a fragment component. We validate syntax only (pass-through) — the
# resource server enforces the audience when it introspects the token.
def valid_resource_indicator?(value)
return false if value.blank?
uri = URI.parse(value)
uri.absolute? && uri.fragment.nil?
rescue URI::InvalidURIError
false
end
def extract_client_credentials def extract_client_credentials
# Try Authorization header first (Basic auth) # Try Authorization header first (Basic auth)
if request.headers["Authorization"]&.start_with?("Basic ") if request.headers["Authorization"]&.start_with?("Basic ")
@@ -0,0 +1,12 @@
class AddResourceToOidcTokens < ActiveRecord::Migration[8.1]
# RFC 8707 Resource Indicators: the audience (target resource server) a token
# is bound to. Threaded from the authorize / device_authorization request
# through the code and carried across refresh rotation onto the access token,
# where introspection reports it as `aud`.
def change
add_column :oidc_authorization_codes, :resource, :string
add_column :oidc_device_codes, :resource, :string
add_column :oidc_access_tokens, :resource, :string
add_column :oidc_refresh_tokens, :resource, :string
end
end
Generated
+5 -1
View File
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_07_19_000002) do ActiveRecord::Schema[8.1].define(version: 2026_07_19_000003) do
create_table "active_storage_attachments", force: :cascade do |t| create_table "active_storage_attachments", force: :cascade do |t|
t.bigint "blob_id", null: false t.bigint "blob_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
@@ -123,6 +123,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_07_19_000002) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "expires_at", null: false t.datetime "expires_at", null: false
t.integer "oidc_authorization_code_id" t.integer "oidc_authorization_code_id"
t.string "resource"
t.datetime "revoked_at" t.datetime "revoked_at"
t.string "scope" t.string "scope"
t.string "token_hmac" t.string "token_hmac"
@@ -149,6 +150,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_07_19_000002) do
t.datetime "expires_at", null: false t.datetime "expires_at", null: false
t.string "nonce" t.string "nonce"
t.string "redirect_uri", null: false t.string "redirect_uri", null: false
t.string "resource"
t.string "scope" t.string "scope"
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.boolean "used", default: false, null: false t.boolean "used", default: false, null: false
@@ -173,6 +175,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_07_19_000002) do
t.integer "interval", default: 5, null: false t.integer "interval", default: 5, null: false
t.datetime "last_polled_at" t.datetime "last_polled_at"
t.string "nonce" t.string "nonce"
t.string "resource"
t.string "scope" t.string "scope"
t.string "status", default: "pending", null: false t.string "status", default: "pending", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
@@ -193,6 +196,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_07_19_000002) do
t.datetime "expires_at", null: false t.datetime "expires_at", null: false
t.integer "oidc_access_token_id", null: false t.integer "oidc_access_token_id", null: false
t.integer "oidc_authorization_code_id" t.integer "oidc_authorization_code_id"
t.string "resource"
t.datetime "revoked_at" t.datetime "revoked_at"
t.string "scope" t.string "scope"
t.integer "token_family_id" t.integer "token_family_id"
@@ -0,0 +1,51 @@
# 0004 — Resource Indicators (RFC 8707), pass-through binding
**Status:** Accepted · **Date:** 2026-07-19
## Decision
Clinch accepts the RFC 8707 `resource` parameter at `/oauth/authorize` and
`/oauth/device_authorization`, binds it to the issued token as its audience, and
reports it at introspection as `aud`. Validation is **syntax-only (pass-through)**:
the value must be an absolute URI without a fragment; clinch does not maintain a
registry of resource servers. The **resource server enforces** the audience when it
introspects the token.
## Context
Without an audience, an access token minted for one API could be replayed against
another API that also trusts clinch (the confused-deputy problem). RFC 8707 lets the
client name the target (`resource=https://c2a2.example.com`); the token is then bound
to that audience and is useless elsewhere.
Two ways to handle the value:
- **Pass-through (chosen):** validate the URI syntax, store it, report it as `aud`.
Enforcement is at the resource server, which already validates tokens via
introspection ([0001](0001-opaque-vs-jwt-access-tokens.md)) and simply checks
`aud == <its own identifier>`. A token bound to an arbitrary audience is worthless
anywhere that isn't that audience, so no clinch-side registry is needed.
- **Registry-gated (not chosen):** reject unknown resources with `invalid_target`.
Catches typos early but requires clinch to model and maintain resource-server
identities, which it does not have today.
## Implementation notes
- `resource` is threaded from the authorize / device_authorization request onto the
authorization/device code, then onto the access and refresh tokens, and is carried
across refresh rotation so re-issued tokens keep the audience.
- Invalid resources are rejected with `error=invalid_target` (redirect for authorize,
JSON 400 for device_authorization). Validation lives in
`OidcController#valid_resource_indicator?` (absolute URI, no fragment).
- Introspection returns `aud = access_token.resource` when bound, falling back to the
client_id when no resource indicator was used.
- Binding happens at authorization time and is carried through; token-time `resource`
narrowing is not implemented (not needed for the MCP / device flows).
## Consequences
- Tokens can be scoped to a single resource server, closing the cross-service replay
path — enforced where it belongs, at the resource server.
- Completes the clinch-side OAuth surface MCP connectors rely on (with DCR
[0003](0003-dynamic-client-registration.md) and introspection). The remaining MCP
piece, Protected Resource Metadata (RFC 9728), lives on the resource server.
+1
View File
@@ -10,3 +10,4 @@ Each file is one decision. Newest decisions get the next number.
| [0001](0001-opaque-vs-jwt-access-tokens.md) | Access tokens are opaque (not JWT); resource servers use introspection | | [0001](0001-opaque-vs-jwt-access-tokens.md) | Access tokens are opaque (not JWT); resource servers use introspection |
| [0002](0002-device-authorization-grant.md) | CLI/agent auth uses the OAuth 2.0 Device Authorization Grant (RFC 8628) | | [0002](0002-device-authorization-grant.md) | CLI/agent auth uses the OAuth 2.0 Device Authorization Grant (RFC 8628) |
| [0003](0003-dynamic-client-registration.md) | Dynamic Client Registration (RFC 7591), runtime-gated + default-deny | | [0003](0003-dynamic-client-registration.md) | Dynamic Client Registration (RFC 7591), runtime-gated + default-deny |
| [0004](0004-resource-indicators.md) | Resource Indicators (RFC 8707) bind token audience; pass-through validation |
@@ -0,0 +1,123 @@
require "test_helper"
# RFC 8707 Resource Indicators: a client names the target resource server via the
# `resource` parameter; clinch binds it to the token as `aud` and reports it at
# introspection. Validation is syntax-only (pass-through) — the resource server
# enforces the audience.
class OidcResourceIndicatorsTest < ActionDispatch::IntegrationTest
DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code".freeze
RESOURCE = "https://c2a2.example.com".freeze
# RFC 7636 Appendix B example PKCE pair.
PKCE_VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk".freeze
PKCE_CHALLENGE = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM".freeze
def setup
@group = Group.create!(name: "resource-testers", description: "test")
@user = User.create!(email_address: "resource_test@example.com", password: "password123")
@user.groups << @group
@cli = Application.create!(name: "Resource CLI", slug: "resource-cli", app_type: "oidc", is_public_client: true, active: true)
@cli.allowed_groups << @group
@web = Application.create!(name: "Resource Web", slug: "resource-web", app_type: "oidc",
is_public_client: true, active: true, skip_consent: true, redirect_uris: ["https://app.example.com/cb"].to_json)
@web.allowed_groups << @group
@resource_secret = "resource-server-secret-value-abcdefghij"
@resource = Application.create!(name: "Resource RS", slug: "resource-rs2", app_type: "oidc",
client_secret: @resource_secret, active: true)
end
def teardown
Current.session = nil
[@cli, @web, @resource].each do |app|
OidcRefreshToken.where(application: app).delete_all
OidcAccessToken.where(application: app).delete_all
OidcDeviceCode.where(application: app).delete_all
OidcAuthorizationCode.where(application: app).delete_all
OidcUserConsent.where(application: app).delete_all
end
end
# --- Authorization code flow ----------------------------------------------
test "authorize binds the resource so introspection reports it as aud" do
sign_in_as(@user)
get "/oauth/authorize", params: {
response_type: "code", client_id: @web.client_id,
redirect_uri: "https://app.example.com/cb", scope: "openid",
code_challenge: PKCE_CHALLENGE, code_challenge_method: "S256",
resource: RESOURCE
}
assert_response :redirect
code = Rack::Utils.parse_query(URI(@response.location).query)["code"]
assert code.present?
post "/oauth/token", params: {
grant_type: "authorization_code", code: code,
redirect_uri: "https://app.example.com/cb",
client_id: @web.client_id, code_verifier: PKCE_VERIFIER
}
assert_response :success
tokens = JSON.parse(@response.body)
assert_equal RESOURCE, introspect(tokens["access_token"])["aud"]
# The bound audience survives refresh rotation.
post "/oauth/token", params: {grant_type: "refresh_token", refresh_token: tokens["refresh_token"], client_id: @web.client_id}
assert_response :success
rotated = JSON.parse(@response.body)
assert_equal RESOURCE, introspect(rotated["access_token"])["aud"]
end
test "authorize rejects an invalid resource with invalid_target" do
sign_in_as(@user)
get "/oauth/authorize", params: {
response_type: "code", client_id: @web.client_id,
redirect_uri: "https://app.example.com/cb", scope: "openid",
resource: "https://c2a2.example.com/path#frag"
}
assert_response :redirect
assert_includes @response.location, "error=invalid_target"
end
# --- 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}
assert_response :success
auth = JSON.parse(@response.body)
dc = OidcDeviceCode.find_by_user_code(auth["user_code"])
assert_equal RESOURCE, dc.resource
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}
assert_response :success
access = JSON.parse(@response.body)["access_token"]
assert_equal RESOURCE, introspect(access)["aud"]
end
test "device_authorization rejects an invalid resource" do
post "/oauth/device_authorization", params: {client_id: @cli.client_id, resource: "not-an-absolute-uri"}
assert_response :bad_request
assert_equal "invalid_target", JSON.parse(@response.body)["error"]
end
# --- 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"]
end
private
def introspect(token)
post "/oauth/introspect", params: {token: token, client_id: @resource.client_id, client_secret: @resource_secret}
JSON.parse(@response.body)
end
end