diff --git a/app/controllers/oidc_controller.rb b/app/controllers/oidc_controller.rb index 8ec4e51..6c466d9 100644 --- a/app/controllers/oidc_controller.rb +++ b/app/controllers/oidc_controller.rb @@ -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 ") diff --git a/db/migrate/20260719000003_add_resource_to_oidc_tokens.rb b/db/migrate/20260719000003_add_resource_to_oidc_tokens.rb new file mode 100644 index 0000000..96fedfe --- /dev/null +++ b/db/migrate/20260719000003_add_resource_to_oidc_tokens.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 38dbe99..5c1fda7 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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" diff --git a/docs/decisions/0004-resource-indicators.md b/docs/decisions/0004-resource-indicators.md new file mode 100644 index 0000000..52879c5 --- /dev/null +++ b/docs/decisions/0004-resource-indicators.md @@ -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 == `. 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. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index cfd92c2..860868b 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -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 | diff --git a/test/controllers/oidc_resource_indicators_test.rb b/test/controllers/oidc_resource_indicators_test.rb new file mode 100644 index 0000000..bde496c --- /dev/null +++ b/test/controllers/oidc_resource_indicators_test.rb @@ -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