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 ")
|
||||
|
||||
Reference in New Issue
Block a user