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
|
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
@@ -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.
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user