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:
co-authored by
Claude Opus 4.8
parent
7149b98b7b
commit
2defa26a87
@@ -102,10 +102,18 @@ class OidcController < ApplicationController
|
||||
return
|
||||
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!(
|
||||
application: application,
|
||||
scope: requested_scope,
|
||||
nonce: params[:nonce].presence,
|
||||
resource: resource,
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: code_challenge.present? ? (code_challenge_method || "S256") : nil
|
||||
)
|
||||
@@ -136,6 +144,7 @@ class OidcController < ApplicationController
|
||||
response_type = params[:response_type]
|
||||
code_challenge = params[:code_challenge]
|
||||
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).
|
||||
@@ -165,6 +174,16 @@ class OidcController < ApplicationController
|
||||
return
|
||||
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)
|
||||
if code_challenge.present?
|
||||
unless code_challenge_method == "S256"
|
||||
@@ -252,6 +271,7 @@ class OidcController < ApplicationController
|
||||
scope: scope,
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: code_challenge_method,
|
||||
resource: resource,
|
||||
claims_requests: parsed_claims&.to_json
|
||||
}
|
||||
# Store the current URL (with all OAuth params) for redirect after authentication
|
||||
@@ -342,6 +362,7 @@ class OidcController < ApplicationController
|
||||
nonce: nonce,
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: code_challenge_method,
|
||||
resource: resource,
|
||||
claims_requests: parsed_claims || {},
|
||||
auth_time: Current.session.created_at.to_i,
|
||||
acr: Current.session.acr,
|
||||
@@ -367,6 +388,7 @@ class OidcController < ApplicationController
|
||||
nonce: nonce,
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: code_challenge_method,
|
||||
resource: resource,
|
||||
claims_requests: parsed_claims || {},
|
||||
auth_time: Current.session.created_at.to_i,
|
||||
acr: Current.session.acr,
|
||||
@@ -389,6 +411,7 @@ class OidcController < ApplicationController
|
||||
scope: scope,
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: code_challenge_method,
|
||||
resource: resource,
|
||||
claims_requests: parsed_claims&.to_json
|
||||
}
|
||||
|
||||
@@ -474,6 +497,7 @@ class OidcController < ApplicationController
|
||||
nonce: oauth_params["nonce"],
|
||||
code_challenge: oauth_params["code_challenge"],
|
||||
code_challenge_method: oauth_params["code_challenge_method"],
|
||||
resource: oauth_params["resource"],
|
||||
claims_requests: parsed_claims,
|
||||
auth_time: Current.session.created_at.to_i,
|
||||
acr: Current.session.acr,
|
||||
@@ -603,7 +627,8 @@ class OidcController < ApplicationController
|
||||
access_token_record = OidcAccessToken.create!(
|
||||
application: application,
|
||||
user: user,
|
||||
scope: granted_scope
|
||||
scope: granted_scope,
|
||||
resource: device_code.resource
|
||||
)
|
||||
|
||||
refresh_token_record = OidcRefreshToken.create!(
|
||||
@@ -612,7 +637,8 @@ class OidcController < ApplicationController
|
||||
oidc_access_token: access_token_record,
|
||||
scope: granted_scope,
|
||||
auth_time: device_code.auth_time,
|
||||
acr: device_code.acr
|
||||
acr: device_code.acr,
|
||||
resource: device_code.resource
|
||||
)
|
||||
|
||||
id_token = OidcJwtService.generate_id_token(
|
||||
@@ -747,7 +773,8 @@ class OidcController < ApplicationController
|
||||
application: application,
|
||||
user: user,
|
||||
scope: auth_code.scope,
|
||||
oidc_authorization_code: auth_code
|
||||
oidc_authorization_code: auth_code,
|
||||
resource: auth_code.resource
|
||||
)
|
||||
|
||||
# Generate refresh token (opaque, with hashing)
|
||||
@@ -758,7 +785,8 @@ class OidcController < ApplicationController
|
||||
oidc_authorization_code: auth_code,
|
||||
scope: auth_code.scope,
|
||||
auth_time: auth_code.auth_time,
|
||||
acr: auth_code.acr
|
||||
acr: auth_code.acr,
|
||||
resource: auth_code.resource
|
||||
)
|
||||
|
||||
# Find user consent for this application
|
||||
@@ -888,7 +916,8 @@ class OidcController < ApplicationController
|
||||
application: application,
|
||||
user: user,
|
||||
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)
|
||||
@@ -900,7 +929,8 @@ class OidcController < ApplicationController
|
||||
scope: refresh_token_record.scope,
|
||||
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
|
||||
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
|
||||
@@ -1101,7 +1131,9 @@ class OidcController < ApplicationController
|
||||
exp: access_token.expires_at.to_i,
|
||||
iat: access_token.created_at.to_i,
|
||||
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,
|
||||
groups: user.groups.pluck(:name)
|
||||
}
|
||||
@@ -1333,6 +1365,17 @@ class OidcController < ApplicationController
|
||||
{valid: true}
|
||||
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
|
||||
# Try Authorization header first (Basic auth)
|
||||
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
@@ -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_000002) do
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_07_19_000003) do
|
||||
create_table "active_storage_attachments", force: :cascade do |t|
|
||||
t.bigint "blob_id", 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 "expires_at", null: false
|
||||
t.integer "oidc_authorization_code_id"
|
||||
t.string "resource"
|
||||
t.datetime "revoked_at"
|
||||
t.string "scope"
|
||||
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.string "nonce"
|
||||
t.string "redirect_uri", null: false
|
||||
t.string "resource"
|
||||
t.string "scope"
|
||||
t.datetime "updated_at", 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.datetime "last_polled_at"
|
||||
t.string "nonce"
|
||||
t.string "resource"
|
||||
t.string "scope"
|
||||
t.string "status", default: "pending", 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.integer "oidc_access_token_id", null: false
|
||||
t.integer "oidc_authorization_code_id"
|
||||
t.string "resource"
|
||||
t.datetime "revoked_at"
|
||||
t.string "scope"
|
||||
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.
|
||||
@@ -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 |
|
||||
| [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 |
|
||||
| [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
|
||||
Reference in New Issue
Block a user