From b4e4d11ad3480ed0df0991a6d1490d574dfb0c2d Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 27 Aug 2026 23:14:54 -0700 Subject: [PATCH 01/22] Support confidential clients with client_secret_post beta2's bc3 authorization server advertises client_secret_post, and hosted connectors register as confidential clients. Clients now carry a token_endpoint_auth_method (none or client_secret_post); confidential clients get a generated client_secret at creation, returned once in the DCR response. The token endpoint authenticates confidential clients on both grants, answering invalid_client 401 when the posted secret is missing or wrong. Public loopback clients are unchanged. Discovery advertises both auth methods. Revocation stays unauthenticated by design: revoking requires holding the token itself, which is the same capability the credential guards. --- app/controllers/oauth/clients_controller.rb | 14 +- app/controllers/oauth/metadata_controller.rb | 2 +- app/controllers/oauth/tokens_controller.rb | 10 ++ app/models/oauth/client.rb | 16 ++ ...20260827110000_add_oauth_client_secrets.rb | 8 + db/schema.rb | 2 + db/schema_sqlite.rb | 2 + test/fixtures/oauth/clients.yml | 13 ++ test/integration/oauth_flow_test.rb | 156 ++++++++++++++++++ test/models/oauth/client_test.rb | 35 ++++ 10 files changed, 253 insertions(+), 5 deletions(-) create mode 100644 db/migrate/20260827110000_add_oauth_client_secrets.rb diff --git a/app/controllers/oauth/clients_controller.rb b/app/controllers/oauth/clients_controller.rb index 445caf7507..bcabfac473 100644 --- a/app/controllers/oauth/clients_controller.rb +++ b/app/controllers/oauth/clients_controller.rb @@ -14,6 +14,7 @@ def create name: registered_client_name, redirect_uris: Array(params[:redirect_uris]), scopes: validated_scopes, + token_endpoint_auth_method: registered_auth_method, dynamically_registered: true render json: dynamic_client_registration_response(client), status: :created @@ -53,11 +54,15 @@ def validate_redirect_uri_origins end def validate_auth_method - unless performed? || params[:token_endpoint_auth_method].blank? || params[:token_endpoint_auth_method] == "none" - oauth_error "invalid_client_metadata", "Only 'none' token_endpoint_auth_method is supported" + unless performed? || registered_auth_method.in?(%w[ none client_secret_post ]) + oauth_error "invalid_client_metadata", "Only 'none' and 'client_secret_post' token_endpoint_auth_methods are supported" end end + def registered_auth_method + params[:token_endpoint_auth_method].presence || "none" + end + def all_registrable_uris?(uris) uris.is_a?(Array) && uris.all? { |uri| uri.is_a?(String) && registrable_uri?(uri) } @@ -94,12 +99,13 @@ def validated_scopes def dynamic_client_registration_response(client) { client_id: client.client_id, + client_secret: client.client_secret, client_name: client.name, redirect_uris: client.redirect_uris, - token_endpoint_auth_method: "none", + token_endpoint_auth_method: client.token_endpoint_auth_method, grant_types: %w[ authorization_code refresh_token ], response_types: %w[ code ], scope: client.scopes.join(" ") - } + }.compact end end diff --git a/app/controllers/oauth/metadata_controller.rb b/app/controllers/oauth/metadata_controller.rb index 3ff6cb817b..0052e25a61 100644 --- a/app/controllers/oauth/metadata_controller.rb +++ b/app/controllers/oauth/metadata_controller.rb @@ -14,7 +14,7 @@ def show response_types_supported: %w[ code ], response_modes_supported: %w[ query ], grant_types_supported: %w[ authorization_code refresh_token ], - token_endpoint_auth_methods_supported: %w[ none ], + token_endpoint_auth_methods_supported: %w[ none client_secret_post ], code_challenge_methods_supported: %w[ S256 ], scopes_supported: %w[ read write ], authorization_response_iss_parameter_supported: true diff --git a/app/controllers/oauth/tokens_controller.rb b/app/controllers/oauth/tokens_controller.rb index 37395472ca..a1ea754983 100644 --- a/app/controllers/oauth/tokens_controller.rb +++ b/app/controllers/oauth/tokens_controller.rb @@ -20,6 +20,8 @@ class Oauth::TokensController < Oauth::BaseController before_action :validate_client_id before_action :set_refresh_scope, unless: :authorization_code_grant? + before_action :authenticate_client + def create if authorization_code_grant? granted = Oauth.canonical_scope(@auth_code.scope) @@ -136,6 +138,14 @@ def granted_scopes(permission) Oauth.canonical_scope(permission).split end + def authenticate_client + client = @client || @access_token.oauth_client + + if client.confidential? && !client.authenticate_secret(params[:client_secret]) + oauth_error "invalid_client", "Client authentication failed", status: :unauthorized + end + end + def token_response(access_token, scope: nil) { access_token: access_token.token, diff --git a/app/models/oauth/client.rb b/app/models/oauth/client.rb index d21a9272d4..b27a3595a5 100644 --- a/app/models/oauth/client.rb +++ b/app/models/oauth/client.rb @@ -11,8 +11,11 @@ class Oauth::Client < ApplicationRecord MAXIMUM_REDIRECT_URI_LENGTH = 2000 validates :redirect_uris, presence: true, length: { maximum: MAXIMUM_REDIRECT_URIS } + validates :token_endpoint_auth_method, inclusion: { in: %w[ none client_secret_post ] } validate :redirect_uris_are_valid + before_create :generate_client_secret, if: :confidential? + attribute :redirect_uris, default: -> { [] } attribute :scopes, default: -> { %w[ read ] } @@ -20,6 +23,15 @@ class Oauth::Client < ApplicationRecord scope :dynamically_registered, -> { where dynamically_registered: true } + def confidential? + token_endpoint_auth_method == "client_secret_post" + end + + def authenticate_secret(secret) + confidential? && client_secret.present? && secret.present? && + ActiveSupport::SecurityUtils.secure_compare(client_secret, secret) + end + def allows_redirect?(uri) redirect_uris.include?(uri) || (loopback_uri?(uri) && matching_loopback?(uri)) end @@ -37,6 +49,10 @@ def allows_scope?(requested_scope) end private + def generate_client_secret + self.client_secret ||= self.class.generate_unique_secure_token + end + def redirect_uris_are_valid if redirect_uris.all? { |uri| uri.is_a?(String) && uri.length <= MAXIMUM_REDIRECT_URI_LENGTH } redirect_uris.each { |uri| validate_redirect_uri(uri) } diff --git a/db/migrate/20260827110000_add_oauth_client_secrets.rb b/db/migrate/20260827110000_add_oauth_client_secrets.rb new file mode 100644 index 0000000000..5dae80573a --- /dev/null +++ b/db/migrate/20260827110000_add_oauth_client_secrets.rb @@ -0,0 +1,8 @@ +class AddOauthClientSecrets < ActiveRecord::Migration[8.2] + def change + change_table :oauth_clients, bulk: true do |t| + t.string :client_secret + t.string :token_endpoint_auth_method, null: false, default: "none" + end + end +end diff --git a/db/schema.rb b/db/schema.rb index d6d72e761b..727fddd1ee 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -438,11 +438,13 @@ create_table "oauth_clients", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "client_id", null: false + t.string "client_secret" t.datetime "created_at", null: false t.boolean "dynamically_registered", default: false t.string "name", null: false t.json "redirect_uris" t.json "scopes" + t.string "token_endpoint_auth_method", default: "none", null: false t.boolean "trusted", default: false t.datetime "updated_at", null: false t.index ["client_id"], name: "index_oauth_clients_on_client_id", unique: true diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index aba377c53f..0b5f7eb61f 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -438,11 +438,13 @@ create_table "oauth_clients", id: :uuid, force: :cascade do |t| t.string "client_id", limit: 255, null: false + t.string "client_secret", limit: 255 t.datetime "created_at", null: false t.boolean "dynamically_registered", default: false t.string "name", limit: 255, null: false t.json "redirect_uris" t.json "scopes" + t.string "token_endpoint_auth_method", limit: 255, default: "none", null: false t.boolean "trusted", default: false t.datetime "updated_at", null: false t.index ["client_id"], name: "index_oauth_clients_on_client_id", unique: true diff --git a/test/fixtures/oauth/clients.yml b/test/fixtures/oauth/clients.yml index 86b6de7264..581f21494d 100644 --- a/test/fixtures/oauth/clients.yml +++ b/test/fixtures/oauth/clients.yml @@ -19,3 +19,16 @@ trusted_client: - write dynamically_registered: false trusted: true + +confidential_client: + client_id: confidential_client_789 + client_secret: confidential_secret_789 + name: Confidential Connector + redirect_uris: + - https://connector.example.com/callback + scopes: + - read + - write + token_endpoint_auth_method: client_secret_post + dynamically_registered: true + trusted: false diff --git a/test/integration/oauth_flow_test.rb b/test/integration/oauth_flow_test.rb index 106e491113..8ea5bd8c93 100644 --- a/test/integration/oauth_flow_test.rb +++ b/test/integration/oauth_flow_test.rb @@ -764,6 +764,115 @@ class OauthFlowTest < ActionDispatch::IntegrationTest end + # Confidential Clients (client_secret_post) + + test "token exchange for confidential client requires client secret" do + client = oauth_clients(:confidential_client) + code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + + code = Oauth::AuthorizationCode.generate \ + client_id: client.client_id, + identity_id: identities(:david).id, + code_challenge: code_challenge, + redirect_uri: "https://connector.example.com/callback", + scope: "read" + + untenanted do + post oauth_token_path, params: { + grant_type: "authorization_code", + code: code, + redirect_uri: "https://connector.example.com/callback", + code_verifier: code_verifier + }, as: :json + end + + assert_response :unauthorized + assert_equal "invalid_client", response.parsed_body["error"] + end + + test "token exchange for confidential client rejects a wrong client secret" do + client = oauth_clients(:confidential_client) + code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + + code = Oauth::AuthorizationCode.generate \ + client_id: client.client_id, + identity_id: identities(:david).id, + code_challenge: code_challenge, + redirect_uri: "https://connector.example.com/callback", + scope: "read" + + untenanted do + post oauth_token_path, params: { + grant_type: "authorization_code", + code: code, + redirect_uri: "https://connector.example.com/callback", + code_verifier: code_verifier, + client_secret: "wrong" + }, as: :json + end + + assert_response :unauthorized + assert_equal "invalid_client", response.parsed_body["error"] + end + + test "token exchange for confidential client succeeds with the client secret" do + client = oauth_clients(:confidential_client) + code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + + code = Oauth::AuthorizationCode.generate \ + client_id: client.client_id, + identity_id: identities(:david).id, + code_challenge: code_challenge, + redirect_uri: "https://connector.example.com/callback", + scope: "read" + + assert_difference "Identity::AccessToken.count", 1 do + untenanted do + post oauth_token_path, params: { + grant_type: "authorization_code", + code: code, + redirect_uri: "https://connector.example.com/callback", + code_verifier: code_verifier, + client_secret: "confidential_secret_789" + }, as: :json + end + end + + assert_response :success + assert_not_nil response.parsed_body["access_token"] + end + + test "refresh grant for confidential client requires the client secret" do + client = oauth_clients(:confidential_client) + token = identities(:david).access_tokens.create!(oauth_client: client) + + untenanted do + post oauth_token_path, params: { + grant_type: "refresh_token", + refresh_token: token.refresh_token, + client_id: client.client_id + }, as: :json + end + + assert_response :unauthorized + assert_equal "invalid_client", response.parsed_body["error"] + + untenanted do + post oauth_token_path, params: { + grant_type: "refresh_token", + refresh_token: token.refresh_token, + client_id: client.client_id, + client_secret: "confidential_secret_789" + }, as: :json + end + + assert_response :success + end + + # Refresh Grant test "refresh grant rotates access and refresh tokens" do @@ -1399,6 +1508,53 @@ class OauthFlowTest < ActionDispatch::IntegrationTest assert_response :bad_request end + test "DCR registers a confidential client with client_secret_post" do + untenanted do + post oauth_clients_path, params: { + client_name: "Hosted Connector", + redirect_uris: [ "https://connector.example.com/callback" ], + token_endpoint_auth_method: "client_secret_post" + }, as: :json + end + + assert_response :created + body = response.parsed_body + + assert_equal "client_secret_post", body["token_endpoint_auth_method"] + assert_not_nil body["client_secret"] + assert Oauth::Client.find_by(client_id: body["client_id"]).confidential? + end + + test "DCR omits client_secret for public clients" do + untenanted do + post oauth_clients_path, params: { + client_name: "Public Client", + redirect_uris: [ "http://127.0.0.1:8888/callback" ] + }, as: :json + end + + assert_response :created + body = response.parsed_body + + assert_equal "none", body["token_endpoint_auth_method"] + assert_not body.key?("client_secret") + end + + test "DCR rejects unsupported token_endpoint_auth_method" do + assert_no_difference "Oauth::Client.count" do + untenanted do + post oauth_clients_path, params: { + client_name: "Basic Client", + redirect_uris: [ "https://connector.example.com/callback" ], + token_endpoint_auth_method: "client_secret_basic" + }, as: :json + end + end + + assert_response :bad_request + assert_equal "invalid_client_metadata", response.parsed_body["error"] + end + test "DCR requires redirect_uris" do untenanted do post oauth_clients_path, params: { client_name: "No Redirect" }, as: :json diff --git a/test/models/oauth/client_test.rb b/test/models/oauth/client_test.rb index 1a6d4f1fbd..a3fcb356c8 100644 --- a/test/models/oauth/client_test.rb +++ b/test/models/oauth/client_test.rb @@ -239,6 +239,41 @@ class Oauth::ClientTest < ActiveSupport::TestCase assert_equal %w[ read ], client.scopes end + test "confidential clients generate a client secret on create" do + client = Oauth::Client.create!( + name: "Confidential", + redirect_uris: %w[ https://connector.example.com/callback ], + token_endpoint_auth_method: "client_secret_post" + ) + assert client.confidential? + assert_not_nil client.client_secret + end + + test "public clients get no client secret" do + client = Oauth::Client.create!(name: "Public", redirect_uris: %w[ http://127.0.0.1:8888/callback ]) + assert_not client.confidential? + assert_nil client.client_secret + end + + test "token_endpoint_auth_method must be supported" do + client = Oauth::Client.new( + name: "Basic", + redirect_uris: %w[ http://127.0.0.1/cb ], + token_endpoint_auth_method: "client_secret_basic" + ) + assert_not client.valid? + assert_includes client.errors[:token_endpoint_auth_method], "is not included in the list" + end + + test "authenticate_secret" do + client = oauth_clients(:confidential_client) + assert client.authenticate_secret("confidential_secret_789") + assert_not client.authenticate_secret("wrong") + assert_not client.authenticate_secret("") + assert_not client.authenticate_secret(nil) + assert_not oauth_clients(:mcp_client).authenticate_secret("confidential_secret_789") + end + test "trusted scope" do trusted = Oauth::Client.trusted assert trusted.all?(&:trusted?) From 37f11e0e5da69cf4e8d51b8371211bdd0dd0390c Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 27 Aug 2026 23:39:57 -0700 Subject: [PATCH 02/22] Reject non-string client secrets and mark credential responses no-store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A confidential client posting client_secret as an array or hash passed the presence check and blew up in secure_compare — an unauthenticated 500 where invalid_client belongs. Require a string before comparing. Token and registration responses carry bearer credentials, so they now answer Cache-Control: no-store and Pragma: no-cache per RFC 6749 §5.1 and RFC 7591. --- app/controllers/oauth/base_controller.rb | 5 ++++ app/controllers/oauth/clients_controller.rb | 2 ++ app/controllers/oauth/tokens_controller.rb | 2 ++ app/models/oauth/client.rb | 2 +- test/integration/oauth_flow_test.rb | 27 +++++++++++++++++++++ test/models/oauth/client_test.rb | 1 + 6 files changed, 38 insertions(+), 1 deletion(-) diff --git a/app/controllers/oauth/base_controller.rb b/app/controllers/oauth/base_controller.rb index 21e253ece4..ef43a8dd67 100644 --- a/app/controllers/oauth/base_controller.rb +++ b/app/controllers/oauth/base_controller.rb @@ -32,6 +32,11 @@ def oauth_issuer root_url(script_name: nil) end + def prevent_caching + response.headers["Cache-Control"] = "no-store" + response.headers["Pragma"] = "no-cache" + end + def oauth_error(error, description = nil, status: :bad_request) render json: { error: error, error_description: description }.compact, status: status end diff --git a/app/controllers/oauth/clients_controller.rb b/app/controllers/oauth/clients_controller.rb index bcabfac473..1b2aa4e4b1 100644 --- a/app/controllers/oauth/clients_controller.rb +++ b/app/controllers/oauth/clients_controller.rb @@ -2,6 +2,8 @@ class Oauth::ClientsController < Oauth::BaseController allow_unauthenticated_access skip_forgery_protection + after_action :prevent_caching + rate_limit to: 10, within: 1.minute, only: :create, with: :oauth_rate_limit_exceeded before_action :require_issuance_enabled diff --git a/app/controllers/oauth/tokens_controller.rb b/app/controllers/oauth/tokens_controller.rb index a1ea754983..4e5f6b3d39 100644 --- a/app/controllers/oauth/tokens_controller.rb +++ b/app/controllers/oauth/tokens_controller.rb @@ -2,6 +2,8 @@ class Oauth::TokensController < Oauth::BaseController allow_unauthenticated_access skip_forgery_protection + after_action :prevent_caching + rate_limit to: 20, within: 1.minute, only: :create, with: :oauth_rate_limit_exceeded before_action :require_issuance_enabled diff --git a/app/models/oauth/client.rb b/app/models/oauth/client.rb index b27a3595a5..bd23946824 100644 --- a/app/models/oauth/client.rb +++ b/app/models/oauth/client.rb @@ -28,7 +28,7 @@ def confidential? end def authenticate_secret(secret) - confidential? && client_secret.present? && secret.present? && + confidential? && client_secret.present? && secret.is_a?(String) && secret.present? && ActiveSupport::SecurityUtils.secure_compare(client_secret, secret) end diff --git a/test/integration/oauth_flow_test.rb b/test/integration/oauth_flow_test.rb index 8ea5bd8c93..878250c62e 100644 --- a/test/integration/oauth_flow_test.rb +++ b/test/integration/oauth_flow_test.rb @@ -817,6 +817,32 @@ class OauthFlowTest < ActionDispatch::IntegrationTest assert_equal "invalid_client", response.parsed_body["error"] end + test "token exchange for confidential client rejects a non-string client secret" do + client = oauth_clients(:confidential_client) + code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + + code = Oauth::AuthorizationCode.generate \ + client_id: client.client_id, + identity_id: identities(:david).id, + code_challenge: code_challenge, + redirect_uri: "https://connector.example.com/callback", + scope: "read" + + untenanted do + post oauth_token_path, params: { + grant_type: "authorization_code", + code: code, + redirect_uri: "https://connector.example.com/callback", + code_verifier: code_verifier, + client_secret: [ "confidential_secret_789" ] + }, as: :json + end + + assert_response :unauthorized + assert_equal "invalid_client", response.parsed_body["error"] + end + test "token exchange for confidential client succeeds with the client secret" do client = oauth_clients(:confidential_client) code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" @@ -1522,6 +1548,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest assert_equal "client_secret_post", body["token_endpoint_auth_method"] assert_not_nil body["client_secret"] + assert_equal "no-store", response.headers["Cache-Control"] assert Oauth::Client.find_by(client_id: body["client_id"]).confidential? end diff --git a/test/models/oauth/client_test.rb b/test/models/oauth/client_test.rb index a3fcb356c8..54d587cd03 100644 --- a/test/models/oauth/client_test.rb +++ b/test/models/oauth/client_test.rb @@ -271,6 +271,7 @@ class Oauth::ClientTest < ActiveSupport::TestCase assert_not client.authenticate_secret("wrong") assert_not client.authenticate_secret("") assert_not client.authenticate_secret(nil) + assert_not client.authenticate_secret([ "confidential_secret_789" ]) assert_not oauth_clients(:mcp_client).authenticate_secret("confidential_secret_789") end From 9b757e316e710017fa2bd9c54ae3f6df5aa8ffeb Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 27 Aug 2026 23:41:57 -0700 Subject: [PATCH 03/22] Require client_id with client_secret_post and declare secret expiry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit client_secret_post authenticates with both client_id and client_secret in the request body (RFC 6749 §2.3.1); the token endpoint now checks the posted client_id against the authenticating client on every grant. Registration responses issuing a client_secret now carry client_secret_expires_at: 0 (never expires), required by RFC 7591 §3.2.1, and the discovery test pins both advertised auth methods. --- app/controllers/oauth/clients_controller.rb | 1 + app/controllers/oauth/tokens_controller.rb | 8 ++++-- test/integration/oauth_flow_test.rb | 31 +++++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/app/controllers/oauth/clients_controller.rb b/app/controllers/oauth/clients_controller.rb index 1b2aa4e4b1..47a2332594 100644 --- a/app/controllers/oauth/clients_controller.rb +++ b/app/controllers/oauth/clients_controller.rb @@ -102,6 +102,7 @@ def dynamic_client_registration_response(client) { client_id: client.client_id, client_secret: client.client_secret, + client_secret_expires_at: (0 if client.client_secret), client_name: client.name, redirect_uris: client.redirect_uris, token_endpoint_auth_method: client.token_endpoint_auth_method, diff --git a/app/controllers/oauth/tokens_controller.rb b/app/controllers/oauth/tokens_controller.rb index 4e5f6b3d39..7667c5b918 100644 --- a/app/controllers/oauth/tokens_controller.rb +++ b/app/controllers/oauth/tokens_controller.rb @@ -140,11 +140,15 @@ def granted_scopes(permission) Oauth.canonical_scope(permission).split end + # client_secret_post authenticates with client_id and client_secret in + # the request body, per RFC 6749 §2.3.1. def authenticate_client client = @client || @access_token.oauth_client - if client.confidential? && !client.authenticate_secret(params[:client_secret]) - oauth_error "invalid_client", "Client authentication failed", status: :unauthorized + if client.confidential? + unless params[:client_id] == client.client_id && client.authenticate_secret(params[:client_secret]) + oauth_error "invalid_client", "Client authentication failed", status: :unauthorized + end end end diff --git a/test/integration/oauth_flow_test.rb b/test/integration/oauth_flow_test.rb index 878250c62e..fa99b4cc8a 100644 --- a/test/integration/oauth_flow_test.rb +++ b/test/integration/oauth_flow_test.rb @@ -862,6 +862,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest code: code, redirect_uri: "https://connector.example.com/callback", code_verifier: code_verifier, + client_id: client.client_id, client_secret: "confidential_secret_789" }, as: :json end @@ -871,6 +872,32 @@ class OauthFlowTest < ActionDispatch::IntegrationTest assert_not_nil response.parsed_body["access_token"] end + test "token exchange for confidential client requires a matching client_id" do + client = oauth_clients(:confidential_client) + code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + + code = Oauth::AuthorizationCode.generate \ + client_id: client.client_id, + identity_id: identities(:david).id, + code_challenge: code_challenge, + redirect_uri: "https://connector.example.com/callback", + scope: "read" + + untenanted do + post oauth_token_path, params: { + grant_type: "authorization_code", + code: code, + redirect_uri: "https://connector.example.com/callback", + code_verifier: code_verifier, + client_secret: "confidential_secret_789" + }, as: :json + end + + assert_response :unauthorized + assert_equal "invalid_client", response.parsed_body["error"] + end + test "refresh grant for confidential client requires the client secret" do client = oauth_clients(:confidential_client) token = identities(:david).access_tokens.create!(oauth_client: client) @@ -1277,6 +1304,8 @@ class OauthFlowTest < ActionDispatch::IntegrationTest assert_equal %w[ none ], body["revocation_endpoint_auth_methods_supported"] assert_includes body["grant_types_supported"], "authorization_code" assert_includes body["grant_types_supported"], "refresh_token" + assert_includes body["token_endpoint_auth_methods_supported"], "none" + assert_includes body["token_endpoint_auth_methods_supported"], "client_secret_post" end test "protected resource metadata includes authorization server" do @@ -1548,6 +1577,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest assert_equal "client_secret_post", body["token_endpoint_auth_method"] assert_not_nil body["client_secret"] + assert_equal 0, body["client_secret_expires_at"] assert_equal "no-store", response.headers["Cache-Control"] assert Oauth::Client.find_by(client_id: body["client_id"]).confidential? end @@ -1565,6 +1595,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest assert_equal "none", body["token_endpoint_auth_method"] assert_not body.key?("client_secret") + assert_not body.key?("client_secret_expires_at") end test "DCR rejects unsupported token_endpoint_auth_method" do From 55314a83e245377772043f7c7e6206da98dfd60d Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 27 Aug 2026 23:47:41 -0700 Subject: [PATCH 04/22] Confine client credentials to the request body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rails merges query-string params, so POST /oauth/token?client_id=…& client_secret=… authenticated — leaving reusable secrets in proxy and access logs. Read credentials from request_parameters, so query-string credentials fail with invalid_client. --- app/controllers/oauth/tokens_controller.rb | 7 ++++-- test/integration/oauth_flow_test.rb | 25 ++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/app/controllers/oauth/tokens_controller.rb b/app/controllers/oauth/tokens_controller.rb index 7667c5b918..bc000eb77b 100644 --- a/app/controllers/oauth/tokens_controller.rb +++ b/app/controllers/oauth/tokens_controller.rb @@ -141,12 +141,15 @@ def granted_scopes(permission) end # client_secret_post authenticates with client_id and client_secret in - # the request body, per RFC 6749 §2.3.1. + # the request body, per RFC 6749 §2.3.1 — never the query string, where + # secrets leak into proxy and access logs. def authenticate_client client = @client || @access_token.oauth_client if client.confidential? - unless params[:client_id] == client.client_id && client.authenticate_secret(params[:client_secret]) + credentials = request.request_parameters + + unless credentials["client_id"] == client.client_id && client.authenticate_secret(credentials["client_secret"]) oauth_error "invalid_client", "Client authentication failed", status: :unauthorized end end diff --git a/test/integration/oauth_flow_test.rb b/test/integration/oauth_flow_test.rb index fa99b4cc8a..a68697571f 100644 --- a/test/integration/oauth_flow_test.rb +++ b/test/integration/oauth_flow_test.rb @@ -872,6 +872,31 @@ class OauthFlowTest < ActionDispatch::IntegrationTest assert_not_nil response.parsed_body["access_token"] end + test "token exchange for confidential client rejects credentials in the query string" do + client = oauth_clients(:confidential_client) + code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + + code = Oauth::AuthorizationCode.generate \ + client_id: client.client_id, + identity_id: identities(:david).id, + code_challenge: code_challenge, + redirect_uri: "https://connector.example.com/callback", + scope: "read" + + untenanted do + post oauth_token_path(client_id: client.client_id, client_secret: "confidential_secret_789"), params: { + grant_type: "authorization_code", + code: code, + redirect_uri: "https://connector.example.com/callback", + code_verifier: code_verifier + }, as: :json + end + + assert_response :unauthorized + assert_equal "invalid_client", response.parsed_body["error"] + end + test "token exchange for confidential client requires a matching client_id" do client = oauth_clients(:confidential_client) code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" From e8cf390e95c364ac86d3ad5656da185c4ff46ce5 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 5 Oct 2026 19:28:32 -0700 Subject: [PATCH 05/22] Authenticate the client before validating the refresh grant When a valid refresh token belonged to a confidential client but the request omitted client_id, validate_refresh_client ran first and returned invalid_grant, so the authentication failure never reached authenticate_client's 401 invalid_client. A client can read invalid_grant as a revoked or expired refresh token and discard a still-valid grant. Run authenticate_client after the token is loaded but before validate_refresh_client so client-auth failures surface as invalid_client. The same ordering now covers the code grant: the "issued to this client" check is shared by both grants and runs after authenticate_client, and a missing client_id is checked there too, so a confidential client that omits or misnames it gets invalid_client while a public client gets invalid_request (missing) or invalid_grant (wrong client). --- app/controllers/oauth/tokens_controller.rb | 17 +++++++---- test/integration/oauth_flow_test.rb | 33 ++++++++++++++++++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/app/controllers/oauth/tokens_controller.rb b/app/controllers/oauth/tokens_controller.rb index bc000eb77b..b22c4516c3 100644 --- a/app/controllers/oauth/tokens_controller.rb +++ b/app/controllers/oauth/tokens_controller.rb @@ -19,10 +19,14 @@ class Oauth::TokensController < Oauth::BaseController end before_action :set_refreshable_access_token, unless: :authorization_code_grant? - before_action :validate_client_id - before_action :set_refresh_scope, unless: :authorization_code_grant? + # Authenticate the posted client before validating that the code or refresh + # token was issued to it: a confidential client that fails auth must see + # invalid_client (401), not the invalid_grant that a client_id mismatch would + # raise first — which a client could misread as a revoked grant and discard. before_action :authenticate_client + before_action :validate_client_id + before_action :set_refresh_scope, unless: :authorization_code_grant? def create if authorization_code_grant? @@ -58,7 +62,8 @@ def validate_grant_type # A missing parameter is a malformed request (invalid_request), not a dead # grant (invalid_grant), which a client would act on by discarding it. - # client_id is required of every client: none authenticates by header. + # client_id is required too, but checked after client authentication (see + # validate_client_id), so a confidential client omitting it is invalid_client. # Each is a single string; an array or object from a JSON body or a # name[] form field is just as malformed, and must not reach a lookup. def require_params @@ -68,7 +73,7 @@ def require_params end def required_params - authorization_code_grant? ? %w[ code code_verifier redirect_uri client_id ] : %w[ refresh_token client_id ] + authorization_code_grant? ? %w[ code code_verifier redirect_uri ] : %w[ refresh_token ] end def string_param?(name) @@ -114,7 +119,9 @@ def set_refreshable_access_token # The code or refresh token must have been issued to the client_id in the # request (RFC 6749 §4.1.3, §6). def validate_client_id - unless params[:client_id] == (@client || @access_token.oauth_client).client_id + if params[:client_id].blank? + oauth_error "invalid_request", "Missing required parameter: client_id" + elsif params[:client_id] != (@client || @access_token.oauth_client).client_id oauth_error "invalid_grant", "Grant was not issued to this client" end end diff --git a/test/integration/oauth_flow_test.rb b/test/integration/oauth_flow_test.rb index a68697571f..526ce87d9d 100644 --- a/test/integration/oauth_flow_test.rb +++ b/test/integration/oauth_flow_test.rb @@ -950,6 +950,39 @@ class OauthFlowTest < ActionDispatch::IntegrationTest assert_response :success end + test "code grant for confidential client with a missing or wrong client_id fails as invalid_client" do + code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + client = oauth_clients(:confidential_client) + exchange = { grant_type: "authorization_code", client_secret: "confidential_secret_789", + code: authorization_code_for(client, code_verifier: code_verifier), + redirect_uri: "http://127.0.0.1:8888/callback", code_verifier: code_verifier } + + [ {}, { client_id: oauth_clients(:mcp_client).client_id } ].each do |client_id| + assert_no_difference "Identity::AccessToken.count" do + untenanted { post oauth_token_path, params: exchange.merge(client_id), as: :json } + end + + assert_response :unauthorized + assert_equal "invalid_client", response.parsed_body["error"] + end + end + + test "refresh grant for confidential client omitting client_id fails as invalid_client" do + client = oauth_clients(:confidential_client) + token = identities(:david).access_tokens.create!(oauth_client: client) + + untenanted do + post oauth_token_path, params: { + grant_type: "refresh_token", + refresh_token: token.refresh_token, + client_secret: "confidential_secret_789" + }, as: :json + end + + assert_response :unauthorized + assert_equal "invalid_client", response.parsed_body["error"] + end + # Refresh Grant From ee4fa81a71d18394ed98df7ab4e6be29153353c6 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 10 Sep 2026 11:30:51 -0700 Subject: [PATCH 06/22] Answer failed client_secret_post authentication with 400, not 401 --- app/controllers/oauth/tokens_controller.rb | 10 ++++++---- test/integration/oauth_flow_test.rb | 16 ++++++++-------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/app/controllers/oauth/tokens_controller.rb b/app/controllers/oauth/tokens_controller.rb index b22c4516c3..72490d76a9 100644 --- a/app/controllers/oauth/tokens_controller.rb +++ b/app/controllers/oauth/tokens_controller.rb @@ -22,8 +22,8 @@ class Oauth::TokensController < Oauth::BaseController # Authenticate the posted client before validating that the code or refresh # token was issued to it: a confidential client that fails auth must see - # invalid_client (401), not the invalid_grant that a client_id mismatch would - # raise first — which a client could misread as a revoked grant and discard. + # invalid_client, not the invalid_grant that a client_id mismatch would raise + # first — which a client could misread as a revoked grant and discard. before_action :authenticate_client before_action :validate_client_id before_action :set_refresh_scope, unless: :authorization_code_grant? @@ -149,7 +149,9 @@ def granted_scopes(permission) # client_secret_post authenticates with client_id and client_secret in # the request body, per RFC 6749 §2.3.1 — never the query string, where - # secrets leak into proxy and access logs. + # secrets leak into proxy and access logs. A failure is a 400 invalid_client + # (RFC 6749 §5.2): 401 is reserved for header-based schemes and would owe a + # WWW-Authenticate challenge we have no scheme to fill. def authenticate_client client = @client || @access_token.oauth_client @@ -157,7 +159,7 @@ def authenticate_client credentials = request.request_parameters unless credentials["client_id"] == client.client_id && client.authenticate_secret(credentials["client_secret"]) - oauth_error "invalid_client", "Client authentication failed", status: :unauthorized + oauth_error "invalid_client", "Client authentication failed" end end end diff --git a/test/integration/oauth_flow_test.rb b/test/integration/oauth_flow_test.rb index 526ce87d9d..15cdb0a919 100644 --- a/test/integration/oauth_flow_test.rb +++ b/test/integration/oauth_flow_test.rb @@ -787,7 +787,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest }, as: :json end - assert_response :unauthorized + assert_response :bad_request assert_equal "invalid_client", response.parsed_body["error"] end @@ -813,7 +813,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest }, as: :json end - assert_response :unauthorized + assert_response :bad_request assert_equal "invalid_client", response.parsed_body["error"] end @@ -839,7 +839,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest }, as: :json end - assert_response :unauthorized + assert_response :bad_request assert_equal "invalid_client", response.parsed_body["error"] end @@ -893,7 +893,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest }, as: :json end - assert_response :unauthorized + assert_response :bad_request assert_equal "invalid_client", response.parsed_body["error"] end @@ -919,7 +919,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest }, as: :json end - assert_response :unauthorized + assert_response :bad_request assert_equal "invalid_client", response.parsed_body["error"] end @@ -935,7 +935,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest }, as: :json end - assert_response :unauthorized + assert_response :bad_request assert_equal "invalid_client", response.parsed_body["error"] untenanted do @@ -962,7 +962,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest untenanted { post oauth_token_path, params: exchange.merge(client_id), as: :json } end - assert_response :unauthorized + assert_response :bad_request assert_equal "invalid_client", response.parsed_body["error"] end end @@ -979,7 +979,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest }, as: :json end - assert_response :unauthorized + assert_response :bad_request assert_equal "invalid_client", response.parsed_body["error"] end From 631fd64ff83fe538d1a26d8e2953921be48bac49 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 5 Oct 2026 20:08:53 -0700 Subject: [PATCH 07/22] Authenticate confidential clients before checking the code's verifier and redirect_uri The code grant checked PKCE and redirect_uri before authenticating the client, so with bad credentials a wrong verifier answered invalid_grant and a right one invalid_client. Anyone holding a code but not the secret could tell them apart. Client authentication now precedes every grant check, as it already did for refresh. --- app/controllers/oauth/tokens_controller.rb | 19 ++++++++++++------- test/integration/oauth_flow_test.rb | 15 +++++++++++++++ 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/app/controllers/oauth/tokens_controller.rb b/app/controllers/oauth/tokens_controller.rb index 72490d76a9..c47d082807 100644 --- a/app/controllers/oauth/tokens_controller.rb +++ b/app/controllers/oauth/tokens_controller.rb @@ -13,19 +13,24 @@ class Oauth::TokensController < Oauth::BaseController with_options if: :authorization_code_grant? do before_action :set_auth_code before_action :set_client - before_action :validate_pkce - before_action :validate_redirect_uri - before_action :set_identity end before_action :set_refreshable_access_token, unless: :authorization_code_grant? - # Authenticate the posted client before validating that the code or refresh - # token was issued to it: a confidential client that fails auth must see - # invalid_client, not the invalid_grant that a client_id mismatch would raise - # first — which a client could misread as a revoked grant and discard. + # Authenticate the posted client before validating anything about the grant: + # a confidential client that fails auth must see invalid_client, not an + # invalid_grant from a client_id, verifier or redirect_uri mismatch — which a + # client could misread as a revoked grant and discard, and which would answer + # grant checks for whoever holds a code but not the secret. before_action :authenticate_client before_action :validate_client_id + + with_options if: :authorization_code_grant? do + before_action :validate_pkce + before_action :validate_redirect_uri + before_action :set_identity + end + before_action :set_refresh_scope, unless: :authorization_code_grant? def create diff --git a/test/integration/oauth_flow_test.rb b/test/integration/oauth_flow_test.rb index 15cdb0a919..5f488fff0a 100644 --- a/test/integration/oauth_flow_test.rb +++ b/test/integration/oauth_flow_test.rb @@ -967,6 +967,21 @@ class OauthFlowTest < ActionDispatch::IntegrationTest end end + test "code grant for confidential client authenticates before checking the verifier or redirect_uri" do + code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + client = oauth_clients(:confidential_client) + exchange = { grant_type: "authorization_code", client_id: client.client_id, client_secret: "wrong", + code: authorization_code_for(client, code_verifier: code_verifier), + redirect_uri: "http://127.0.0.1:8888/callback", code_verifier: code_verifier } + + [ { code_verifier: "not-the-verifier" }, { redirect_uri: "http://127.0.0.1:8888/elsewhere" } ].each do |mismatch| + untenanted { post oauth_token_path, params: exchange.merge(mismatch), as: :json } + + assert_response :bad_request + assert_equal "invalid_client", response.parsed_body["error"] + end + end + test "refresh grant for confidential client omitting client_id fails as invalid_client" do client = oauth_clients(:confidential_client) token = identities(:david).access_tokens.create!(oauth_client: client) From 1dc8358ffe642a48bff445ab10d0ecd352020b36 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 5 Oct 2026 20:36:01 -0700 Subject: [PATCH 08/22] Pin that confidential clients sit behind the availability switches A dark server answers 404 before checking any client secret, and a piloted confidential client still has to authenticate with its secret. --- test/integration/oauth_availability_test.rb | 25 +++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/test/integration/oauth_availability_test.rb b/test/integration/oauth_availability_test.rb index 9334635373..b0c3c0d272 100644 --- a/test/integration/oauth_availability_test.rb +++ b/test/integration/oauth_availability_test.rb @@ -200,6 +200,31 @@ class OauthAvailabilityTest < ActionDispatch::IntegrationTest assert_not_equal token.refresh_token, token.reload.refresh_token end + test "a dark server refreshes no confidential grants, even with the right secret" do + client = oauth_clients(:confidential_client) + token = identities(:david).access_tokens.create!(oauth_client: client, permission: :read) + + with_oauth_availability acceptance: false do + untenanted { post oauth_token_path, params: refresh_params(token).merge(client_secret: client.client_secret) } + end + + assert_response :not_found + assert_equal token.refresh_token, token.reload.refresh_token + end + + test "a confidential pilot client still authenticates against a dark server" do + client = oauth_clients(:confidential_client) + token = identities(:david).access_tokens.create!(oauth_client: client, permission: :read) + + with_oauth_availability acceptance: false, issuance: false, pilot_client_ids: [ client.client_id ] do + untenanted { post oauth_token_path, params: refresh_params(token).merge(client_secret: "wrong") } + end + + assert_response :bad_request + assert_equal "invalid_client", response.parsed_body["error"] + assert_equal token.refresh_token, token.reload.refresh_token + end + # Acceptance test "OAuth bearer tokens are refused while acceptance is off" do From 4aa879ca92cde97947ee2aa651f9115edef5afca Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 5 Oct 2026 20:49:18 -0700 Subject: [PATCH 09/22] Authenticate the named client before resolving the grant, and never cache token errors Refresh resolved the token before authenticating the client, so a wrong secret answered invalid_client for a live refresh token and invalid_grant for a dead one: an oracle for whoever holds a token without the secret. The token endpoint now authenticates the client the request names, before any code or refresh token is looked up. A request attempts authentication when it names a confidential client or carries a client_secret, and must then succeed; a request naming a public client attempts none, and a confidential grant presented that way is refused as issued to another client, the same answer as a dead grant. client_id is required up front again (invalid_request), so its absence can't depend on the grant either. prevent_caching ran as an after_action, which a halted callback chain skips, so every error response from the token and registration endpoints went out without Cache-Control: no-store. It is now a before_action. --- app/controllers/oauth/clients_controller.rb | 3 +- app/controllers/oauth/tokens_controller.rb | 49 ++++++++------- test/integration/oauth_flow_test.rb | 68 +++++++++++++++++++++ 3 files changed, 98 insertions(+), 22 deletions(-) diff --git a/app/controllers/oauth/clients_controller.rb b/app/controllers/oauth/clients_controller.rb index 47a2332594..b643df06f7 100644 --- a/app/controllers/oauth/clients_controller.rb +++ b/app/controllers/oauth/clients_controller.rb @@ -2,7 +2,8 @@ class Oauth::ClientsController < Oauth::BaseController allow_unauthenticated_access skip_forgery_protection - after_action :prevent_caching + # A before_action so error responses are not cached either. + before_action :prevent_caching rate_limit to: 10, within: 1.minute, only: :create, with: :oauth_rate_limit_exceeded diff --git a/app/controllers/oauth/tokens_controller.rb b/app/controllers/oauth/tokens_controller.rb index c47d082807..4f2b0398a0 100644 --- a/app/controllers/oauth/tokens_controller.rb +++ b/app/controllers/oauth/tokens_controller.rb @@ -2,12 +2,21 @@ class Oauth::TokensController < Oauth::BaseController allow_unauthenticated_access skip_forgery_protection - after_action :prevent_caching + # A before_action, not after: a halted callback chain skips after_actions, + # and error responses must not be cached either (RFC 6749 §5.1, §5.2). + before_action :prevent_caching rate_limit to: 20, within: 1.minute, only: :create, with: :oauth_rate_limit_exceeded before_action :require_issuance_enabled before_action :validate_grant_type + + # Authenticate the client the request names before looking anything up about + # the grant: a confidential client that fails auth must see invalid_client, + # not an invalid_grant it could misread as a revoked grant and discard, and + # the answer must not depend on whether the code or refresh token is live, + # or it tells whoever holds one without the secret. + before_action :authenticate_client before_action :require_params with_options if: :authorization_code_grant? do @@ -16,13 +25,6 @@ class Oauth::TokensController < Oauth::BaseController end before_action :set_refreshable_access_token, unless: :authorization_code_grant? - - # Authenticate the posted client before validating anything about the grant: - # a confidential client that fails auth must see invalid_client, not an - # invalid_grant from a client_id, verifier or redirect_uri mismatch — which a - # client could misread as a revoked grant and discard, and which would answer - # grant checks for whoever holds a code but not the secret. - before_action :authenticate_client before_action :validate_client_id with_options if: :authorization_code_grant? do @@ -67,8 +69,7 @@ def validate_grant_type # A missing parameter is a malformed request (invalid_request), not a dead # grant (invalid_grant), which a client would act on by discarding it. - # client_id is required too, but checked after client authentication (see - # validate_client_id), so a confidential client omitting it is invalid_client. + # client_id is required of every client: none authenticates by header. # Each is a single string; an array or object from a JSON body or a # name[] form field is just as malformed, and must not reach a lookup. def require_params @@ -78,7 +79,7 @@ def require_params end def required_params - authorization_code_grant? ? %w[ code code_verifier redirect_uri ] : %w[ refresh_token ] + (authorization_code_grant? ? %w[ code code_verifier redirect_uri ] : %w[ refresh_token ]) + %w[ client_id ] end def string_param?(name) @@ -124,9 +125,7 @@ def set_refreshable_access_token # The code or refresh token must have been issued to the client_id in the # request (RFC 6749 §4.1.3, §6). def validate_client_id - if params[:client_id].blank? - oauth_error "invalid_request", "Missing required parameter: client_id" - elsif params[:client_id] != (@client || @access_token.oauth_client).client_id + unless params[:client_id] == (@client || @access_token.oauth_client).client_id oauth_error "invalid_grant", "Grant was not issued to this client" end end @@ -157,18 +156,26 @@ def granted_scopes(permission) # secrets leak into proxy and access logs. A failure is a 400 invalid_client # (RFC 6749 §5.2): 401 is reserved for header-based schemes and would owe a # WWW-Authenticate challenge we have no scheme to fill. + # + # A request attempts authentication when it names a confidential client or + # carries a client secret, and then it must succeed whatever the grant. A + # request naming a public client attempts none; if its grant belongs to a + # confidential client, validate_client_id refuses it as issued to another + # client, the same answer as for a dead grant. So a confidential grant is + # only ever redeemed by the client it names, authenticated. def authenticate_client - client = @client || @access_token.oauth_client + client = Oauth::Client.find_by(client_id: params[:client_id]) if params[:client_id].is_a?(String) + secret = request.request_parameters["client_secret"] - if client.confidential? - credentials = request.request_parameters - - unless credentials["client_id"] == client.client_id && client.authenticate_secret(credentials["client_secret"]) - oauth_error "invalid_client", "Client authentication failed" - end + if (client&.confidential? || secret.present?) && !client_secret_post_authenticated?(client, secret) + oauth_error "invalid_client", "Client authentication failed" end end + def client_secret_post_authenticated?(client, secret) + client&.confidential? && request.request_parameters["client_id"] == client.client_id && client.authenticate_secret(secret) + end + def token_response(access_token, scope: nil) { access_token: access_token.token, diff --git a/test/integration/oauth_flow_test.rb b/test/integration/oauth_flow_test.rb index 5f488fff0a..8a1b1f14d3 100644 --- a/test/integration/oauth_flow_test.rb +++ b/test/integration/oauth_flow_test.rb @@ -781,6 +781,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest untenanted do post oauth_token_path, params: { grant_type: "authorization_code", + client_id: client.client_id, code: code, redirect_uri: "https://connector.example.com/callback", code_verifier: code_verifier @@ -789,6 +790,19 @@ class OauthFlowTest < ActionDispatch::IntegrationTest assert_response :bad_request assert_equal "invalid_client", response.parsed_body["error"] + + # Naming no client at all is a malformed request, whatever the code. + untenanted do + post oauth_token_path, params: { + grant_type: "authorization_code", + code: code, + redirect_uri: "https://connector.example.com/callback", + code_verifier: code_verifier + }, as: :json + end + + assert_response :bad_request + assert_equal "invalid_request", response.parsed_body["error"] end test "token exchange for confidential client rejects a wrong client secret" do @@ -982,6 +996,60 @@ class OauthFlowTest < ActionDispatch::IntegrationTest end end + test "token errors are never cached" do + client = oauth_clients(:confidential_client) + token = identities(:david).access_tokens.create!(oauth_client: client) + + [ { grant_type: "password" }, + { grant_type: "refresh_token", refresh_token: token.refresh_token, client_id: client.client_id, client_secret: "wrong" }, + { grant_type: "refresh_token", refresh_token: "bogus", client_id: oauth_clients(:mcp_client).client_id } ].each do |request| + untenanted { post oauth_token_path, params: request, as: :json } + + assert_response :bad_request + assert_equal "no-store", response.headers["Cache-Control"], request.inspect + assert_equal "no-cache", response.headers["Pragma"] + end + end + + test "registration errors are never cached" do + untenanted { post oauth_clients_path, params: { redirect_uris: [] }, as: :json } + + assert_response :bad_request + assert_equal "no-store", response.headers["Cache-Control"] + end + + test "confidential client authentication does not reveal whether a refresh token is live" do + client = oauth_clients(:confidential_client) + token = identities(:david).access_tokens.create!(oauth_client: client) + + [ token.refresh_token, "not-a-refresh-token" ].each do |refresh_token| + untenanted { post oauth_token_path, params: { grant_type: "refresh_token", refresh_token: refresh_token, client_id: client.client_id, client_secret: "wrong" }, as: :json } + assert_equal "invalid_client", response.parsed_body["error"], "wrong secret" + + untenanted { post oauth_token_path, params: { grant_type: "refresh_token", refresh_token: refresh_token, client_id: oauth_clients(:mcp_client).client_id }, as: :json } + assert_equal "invalid_grant", response.parsed_body["error"], "another client's id" + + untenanted { post oauth_token_path, params: { grant_type: "refresh_token", refresh_token: refresh_token }, as: :json } + assert_equal "invalid_request", response.parsed_body["error"], "no client_id" + end + + assert_equal token.refresh_token, token.reload.refresh_token + end + + test "confidential client authentication does not reveal whether a code is valid" do + client = oauth_clients(:confidential_client) + code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + + [ authorization_code_for(client, code_verifier: code_verifier), "not-a-code" ].each do |code| + untenanted do + post oauth_token_path, params: { grant_type: "authorization_code", client_id: client.client_id, client_secret: "wrong", + code: code, redirect_uri: "http://127.0.0.1:8888/callback", code_verifier: code_verifier }, as: :json + end + + assert_equal "invalid_client", response.parsed_body["error"] + end + end + test "refresh grant for confidential client omitting client_id fails as invalid_client" do client = oauth_clients(:confidential_client) token = identities(:david).access_tokens.create!(oauth_client: client) From 9f62e0653459c6ae7f278bcf4c45bb8c9b4a3d93 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 6 Oct 2026 00:19:35 -0700 Subject: [PATCH 10/22] Accept client_secret_basic at the token endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 6749 §2.3.1: the authorization server MUST support HTTP Basic for clients issued a password. Oauth::ClientAuthentication reads either transport. It base64-decodes the Basic value strictly, splits on the first colon and form-urldecodes each half (Appendix B). A confidential client's secret verifies whichever way it arrives; the registered method is advisory. Basic plus a non-empty body client_secret is two methods at once, refused as invalid_request (§2.3, §5.2). A malformed header, a body client_id naming another client, or Basic naming a public client fails as invalid_client. Every invalid_client is now a 401 carrying a Basic challenge (§5.2). The earlier 400 was chosen because no header scheme existed to name in the challenge, and Basic now is one. The pilot gate keys on the Basic id. Discovery, DCR and the client model accept client_secret_basic. Mirrors basecamp/bc3#13678. --- .../concerns/oauth/client_authentication.rb | 114 +++++++++++ app/controllers/oauth/clients_controller.rb | 4 +- app/controllers/oauth/metadata_controller.rb | 2 +- app/controllers/oauth/tokens_controller.rb | 40 ++-- app/models/oauth/client.rb | 9 +- test/integration/oauth_availability_test.rb | 2 +- .../oauth_client_secret_basic_test.rb | 193 ++++++++++++++++++ test/integration/oauth_flow_test.rb | 33 ++- test/models/oauth/client_test.rb | 14 +- .../oauth_client_credentials_test_helper.rb | 14 ++ 10 files changed, 379 insertions(+), 46 deletions(-) create mode 100644 app/controllers/concerns/oauth/client_authentication.rb create mode 100644 test/integration/oauth_client_secret_basic_test.rb create mode 100644 test/test_helpers/oauth_client_credentials_test_helper.rb diff --git a/app/controllers/concerns/oauth/client_authentication.rb b/app/controllers/concerns/oauth/client_authentication.rb new file mode 100644 index 0000000000..9ea83b5813 --- /dev/null +++ b/app/controllers/concerns/oauth/client_authentication.rb @@ -0,0 +1,114 @@ +# Client credentials arrive one of two ways (RFC 6749 §2.3.1): +# +# - client_secret_basic: an Authorization: Basic header carrying the +# form-urlencoded client_id and client_secret. +# - client_secret_post: client_id and client_secret in the request body. A +# secret in the query string never authenticates, since secrets there leak +# into proxy and access logs. +# +# A confidential client may use either, whichever it registered: the +# registered token_endpoint_auth_method is advisory. Using both at once is +# refused, since a client MUST NOT use more than one method per request (§2.3). +module Oauth::ClientAuthentication + BASIC_SCHEME = /\ABasic /i + + private + # RFC 6749 §5.2 names more than one authentication method invalid_request. + # That answer turns on the request's shape alone, so it says nothing about + # which clients exist. A malformed Basic header, or one naming a different + # client than the body, fails like any other bad credential. + def reject_ambiguous_client_credentials + if client_secret_basic? + if request.request_parameters["client_secret"].present? + oauth_error "invalid_request", "Client credentials must use only one authentication method" + elsif basic_client_credentials.nil? || conflicting_client_id? + client_authentication_failed + end + end + end + + # The client the request authenticates as, or nil. A confidential client + # authenticates with its secret. A public client identifies by client_id + # alone, but never through Basic: it holds no password, so a Basic header + # naming one is refused rather than downgraded to identification. + def authenticated_client + if client = requesting_client + client if client.confidential? ? client_secret_authenticates?(client) : !client_secret_basic? + end + end + + def requesting_client + if oauth_client_id + @requesting_client ||= Oauth::Client.find_by(client_id: oauth_client_id) + end + end + + def client_secret_authenticates?(client) + (client_secret_basic? || request.request_parameters["client_id"] == client.client_id) && + client.authenticate_secret(oauth_client_secret) + end + + # RFC 6749 §5.2: invalid_client may answer 401 to name the schemes the + # server supports, and must when the client tried the Authorization + # header. Basic is the one we support, so every failure names it, and + # the answer is the same for an unknown client and a wrong secret. + # RFC 7617 defines no error parameter for Basic, only realm. + def client_authentication_failed + response.headers["WWW-Authenticate"] = %(Basic realm="#{oauth_issuer}") + oauth_error "invalid_client", "Client authentication failed", status: :unauthorized + end + + # Whether the request used the Basic scheme, well-formed or not. + def client_secret_basic? + request.authorization.to_s.match?(BASIC_SCHEME) + end + + def conflicting_client_id? + params[:client_id].present? && params[:client_id] != basic_client_credentials.first + end + + # A Basic header wins over the body. Only a scalar String names a client. + def oauth_client_id + if client_secret_basic? + basic_client_credentials&.first + elsif params[:client_id].is_a?(String) + params[:client_id] + end + end + + def oauth_client_secret + if client_secret_basic? + basic_client_credentials&.last + else + request.request_parameters["client_secret"] + end + end + + # [client_id, client_secret] from Authorization: Basic, or nil when it's + # malformed. Each half is form-urlencoded before the pair is Basic-encoded + # (RFC 6749 Appendix B), so the split happens on the first raw colon and + # each half decodes after it: %3A is a literal colon and + a space. + def basic_client_credentials + if defined?(@basic_client_credentials) + @basic_client_credentials + else + @basic_client_credentials = decode_basic_client_credentials + end + end + + def decode_basic_client_credentials + decoded = Base64.strict_decode64(request.authorization.to_s.sub(BASIC_SCHEME, "").strip) + client_id, client_secret = decoded.split(":", 2).map { |part| URI.decode_www_form_component(part) } + + if client_id.present? && client_secret.is_a?(String) && client_id.valid_encoding? && client_secret.valid_encoding? + [ client_id, client_secret ] + end + rescue ArgumentError + nil + end + + # The pilot exemption keys on the client that authenticates, header or body. + def piloting_client_id + oauth_client_id + end +end diff --git a/app/controllers/oauth/clients_controller.rb b/app/controllers/oauth/clients_controller.rb index b643df06f7..b8fae0c9c3 100644 --- a/app/controllers/oauth/clients_controller.rb +++ b/app/controllers/oauth/clients_controller.rb @@ -57,8 +57,8 @@ def validate_redirect_uri_origins end def validate_auth_method - unless performed? || registered_auth_method.in?(%w[ none client_secret_post ]) - oauth_error "invalid_client_metadata", "Only 'none' and 'client_secret_post' token_endpoint_auth_methods are supported" + unless performed? || registered_auth_method.in?(Oauth::Client::AUTH_METHODS) + oauth_error "invalid_client_metadata", "token_endpoint_auth_method must be one of #{Oauth::Client::AUTH_METHODS.join(", ")}" end end diff --git a/app/controllers/oauth/metadata_controller.rb b/app/controllers/oauth/metadata_controller.rb index 0052e25a61..be587441bd 100644 --- a/app/controllers/oauth/metadata_controller.rb +++ b/app/controllers/oauth/metadata_controller.rb @@ -14,7 +14,7 @@ def show response_types_supported: %w[ code ], response_modes_supported: %w[ query ], grant_types_supported: %w[ authorization_code refresh_token ], - token_endpoint_auth_methods_supported: %w[ none client_secret_post ], + token_endpoint_auth_methods_supported: Oauth::Client::AUTH_METHODS, code_challenge_methods_supported: %w[ S256 ], scopes_supported: %w[ read write ], authorization_response_iss_parameter_supported: true diff --git a/app/controllers/oauth/tokens_controller.rb b/app/controllers/oauth/tokens_controller.rb index 4f2b0398a0..788d6450a0 100644 --- a/app/controllers/oauth/tokens_controller.rb +++ b/app/controllers/oauth/tokens_controller.rb @@ -1,4 +1,6 @@ class Oauth::TokensController < Oauth::BaseController + include Oauth::ClientAuthentication + allow_unauthenticated_access skip_forgery_protection @@ -69,7 +71,8 @@ def validate_grant_type # A missing parameter is a malformed request (invalid_request), not a dead # grant (invalid_grant), which a client would act on by discarding it. - # client_id is required of every client: none authenticates by header. + # client_id is required of every client, in the body unless a Basic + # header carries it. # Each is a single string; an array or object from a JSON body or a # name[] form field is just as malformed, and must not reach a lookup. def require_params @@ -79,7 +82,7 @@ def require_params end def required_params - (authorization_code_grant? ? %w[ code code_verifier redirect_uri ] : %w[ refresh_token ]) + %w[ client_id ] + (authorization_code_grant? ? %w[ code code_verifier redirect_uri ] : %w[ refresh_token ]) + (client_secret_basic? ? [] : %w[ client_id ]) end def string_param?(name) @@ -125,7 +128,7 @@ def set_refreshable_access_token # The code or refresh token must have been issued to the client_id in the # request (RFC 6749 §4.1.3, §6). def validate_client_id - unless params[:client_id] == (@client || @access_token.oauth_client).client_id + unless oauth_client_id == (@client || @access_token.oauth_client).client_id oauth_error "invalid_grant", "Grant was not issued to this client" end end @@ -151,29 +154,24 @@ def granted_scopes(permission) Oauth.canonical_scope(permission).split end - # client_secret_post authenticates with client_id and client_secret in - # the request body, per RFC 6749 §2.3.1 — never the query string, where - # secrets leak into proxy and access logs. A failure is a 400 invalid_client - # (RFC 6749 §5.2): 401 is reserved for header-based schemes and would owe a - # WWW-Authenticate challenge we have no scheme to fill. - # - # A request attempts authentication when it names a confidential client or - # carries a client secret, and then it must succeed whatever the grant. A - # request naming a public client attempts none; if its grant belongs to a - # confidential client, validate_client_id refuses it as issued to another - # client, the same answer as for a dead grant. So a confidential grant is - # only ever redeemed by the client it names, authenticated. + # A request attempts client authentication when it uses Basic, names a + # confidential client, or carries a client secret, and then it must + # authenticate a confidential client whatever the grant (see + # Oauth::ClientAuthentication). A request naming a public client attempts + # none; if its grant belongs to a confidential client, validate_client_id + # refuses it as issued to another client, the same answer as for a dead + # grant. So a confidential grant is only ever redeemed by the client it + # names, authenticated. def authenticate_client - client = Oauth::Client.find_by(client_id: params[:client_id]) if params[:client_id].is_a?(String) - secret = request.request_parameters["client_secret"] + reject_ambiguous_client_credentials - if (client&.confidential? || secret.present?) && !client_secret_post_authenticated?(client, secret) - oauth_error "invalid_client", "Client authentication failed" + unless performed? || !attempts_client_authentication? || authenticated_client&.confidential? + client_authentication_failed end end - def client_secret_post_authenticated?(client, secret) - client&.confidential? && request.request_parameters["client_id"] == client.client_id && client.authenticate_secret(secret) + def attempts_client_authentication? + client_secret_basic? || requesting_client&.confidential? || oauth_client_secret.present? end def token_response(access_token, scope: nil) diff --git a/app/models/oauth/client.rb b/app/models/oauth/client.rb index bd23946824..538f55bbfb 100644 --- a/app/models/oauth/client.rb +++ b/app/models/oauth/client.rb @@ -1,4 +1,9 @@ class Oauth::Client < ApplicationRecord + # The secret transports a confidential client may register. Either verifies + # the secret, however it arrives: the registered method is advisory. + SECRET_AUTH_METHODS = %w[ client_secret_post client_secret_basic ] + AUTH_METHODS = %w[ none ] + SECRET_AUTH_METHODS + has_many :access_tokens, class_name: "Identity::AccessToken", foreign_key: :oauth_client_id, inverse_of: :oauth_client has_secure_token :client_id, length: 32 @@ -11,7 +16,7 @@ class Oauth::Client < ApplicationRecord MAXIMUM_REDIRECT_URI_LENGTH = 2000 validates :redirect_uris, presence: true, length: { maximum: MAXIMUM_REDIRECT_URIS } - validates :token_endpoint_auth_method, inclusion: { in: %w[ none client_secret_post ] } + validates :token_endpoint_auth_method, inclusion: { in: AUTH_METHODS } validate :redirect_uris_are_valid before_create :generate_client_secret, if: :confidential? @@ -24,7 +29,7 @@ class Oauth::Client < ApplicationRecord def confidential? - token_endpoint_auth_method == "client_secret_post" + token_endpoint_auth_method.in?(SECRET_AUTH_METHODS) end def authenticate_secret(secret) diff --git a/test/integration/oauth_availability_test.rb b/test/integration/oauth_availability_test.rb index b0c3c0d272..73b3562390 100644 --- a/test/integration/oauth_availability_test.rb +++ b/test/integration/oauth_availability_test.rb @@ -220,7 +220,7 @@ class OauthAvailabilityTest < ActionDispatch::IntegrationTest untenanted { post oauth_token_path, params: refresh_params(token).merge(client_secret: "wrong") } end - assert_response :bad_request + assert_response :unauthorized assert_equal "invalid_client", response.parsed_body["error"] assert_equal token.refresh_token, token.reload.refresh_token end diff --git a/test/integration/oauth_client_secret_basic_test.rb b/test/integration/oauth_client_secret_basic_test.rb new file mode 100644 index 0000000000..29d12760be --- /dev/null +++ b/test/integration/oauth_client_secret_basic_test.rb @@ -0,0 +1,193 @@ +require "test_helper" + +# client_secret_basic beside client_secret_post (RFC 6749 §2.3.1). +class OauthClientSecretBasicTest < ActionDispatch::IntegrationTest + include OauthAvailabilityTestHelper, OauthClientCredentialsTestHelper + + CODE_VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + + setup do + @client = oauth_clients(:confidential_client) + @secret = "confidential_secret_789" + end + + # client_secret_basic at the token endpoint + + test "a confidential client refreshes with HTTP Basic alone" do + token = grant_for(@client) + + refresh token, headers: basic(@client.client_id, @secret) + + assert_response :success + assert_not_equal token.refresh_token, token.reload.refresh_token + assert_equal token.refresh_token, response.parsed_body["refresh_token"] + end + + test "a confidential client redeems a code with HTTP Basic alone" do + code = authorization_code_for(@client) + + assert_difference "Identity::AccessToken.count", +1 do + untenanted do + post oauth_token_path, params: { grant_type: "authorization_code", code: code, + redirect_uri: "https://connector.example.com/callback", code_verifier: CODE_VERIFIER }, + headers: basic(@client.client_id, @secret) + end + end + + assert_response :success + end + + test "Basic credentials are form-urlencoded before they are encoded, so %3A and + decode after the split" do + client = Oauth::Client.create! name: "Colon", redirect_uris: %w[ https://connector.example.com/callback ], + token_endpoint_auth_method: "client_secret_basic", client_secret: "pa:ss word+" + token = grant_for(client) + + refresh token, headers: basic(client.client_id, "pa:ss word+") + + assert_response :success + end + + test "Basic beside a body client_id naming the same client authenticates" do + token = grant_for(@client) + + refresh token, params: { client_id: @client.client_id }, headers: basic(@client.client_id, @secret) + + assert_response :success + end + + test "Basic beside an empty body client_secret authenticates" do + token = grant_for(@client) + + refresh token, params: { client_secret: "" }, headers: basic(@client.client_id, @secret) + + assert_response :success + end + + test "Basic and a body client_secret together are two methods, refused as invalid_request" do + token = grant_for(@client) + + [ @secret, "something-else" ].each do |body_secret| + refresh token, params: { client_id: @client.client_id, client_secret: body_secret }, headers: basic(@client.client_id, @secret) + + assert_response :bad_request + assert_equal "invalid_request", response.parsed_body["error"] + end + + assert_equal token.refresh_token, token.reload.refresh_token + end + + test "Basic naming one client while the body names another fails as invalid_client" do + token = grant_for(@client) + + refresh token, params: { client_id: oauth_clients(:mcp_client).client_id }, headers: basic(@client.client_id, @secret) + + assert_client_authentication_failed + assert_equal token.refresh_token, token.reload.refresh_token + end + + test "a malformed Basic header fails as invalid_client" do + token = grant_for(@client) + + [ "Basic not-base64!", "Basic #{Base64.strict_encode64("no-colon")}", "Basic #{Base64.strict_encode64(":#{@secret}")}", + "Basic #{Base64.strict_encode64("#{@client.client_id}:%ZZ")}", "Basic #{Base64.strict_encode64("#{@client.client_id}:%FF")}" ].each do |header| + refresh token, headers: { "Authorization" => header } + + assert_client_authentication_failed header + end + + assert_equal token.refresh_token, token.reload.refresh_token + end + + test "Basic naming a public client fails: a public client holds no password" do + client = oauth_clients(:mcp_client) + token = grant_for(client) + + [ "", "anything" ].each do |secret| + refresh token, headers: basic(client.client_id, secret) + + assert_client_authentication_failed + end + + assert_equal token.refresh_token, token.reload.refresh_token + end + + test "a wrong Basic secret and an unknown Basic client get the same 401 and Basic challenge" do + token = grant_for(@client) + + refresh token, headers: basic(@client.client_id, "wrong") + wrong_secret = [ response.status, response.parsed_body, response.headers["WWW-Authenticate"] ] + + refresh token, headers: basic("no-such-client", "wrong") + unknown_client = [ response.status, response.parsed_body, response.headers["WWW-Authenticate"] ] + + assert_client_authentication_failed + assert_equal wrong_secret, unknown_client + assert_equal token.refresh_token, token.reload.refresh_token + end + + test "a failed client_secret_post authentication is a 401 that names the Basic scheme" do + token = grant_for(@client) + + refresh token, params: { client_id: @client.client_id, client_secret: "wrong" } + + assert_client_authentication_failed + assert_equal "no-store", response.headers["Cache-Control"] + end + + test "the pilot gate reads the client a Basic header names" do + token = grant_for(@client) + + with_oauth_availability acceptance: false, issuance: false, pilot_client_ids: [ @client.client_id ] do + refresh token, headers: basic(@client.client_id, @secret) + end + + assert_response :success + end + + test "every advertised secret method authenticates at the token endpoint" do + untenanted { get "/.well-known/oauth-authorization-server" } + methods = response.parsed_body["token_endpoint_auth_methods_supported"] + + assert_equal %w[ none client_secret_post client_secret_basic ], methods + + { "client_secret_post" => [ { client_id: @client.client_id, client_secret: @secret }, {} ], + "client_secret_basic" => [ {}, basic(@client.client_id, @secret) ] }.each do |method, (params, headers)| + refresh grant_for(@client), params: params, headers: headers + + assert_response :success, method + end + end + + test "DCR registers a client_secret_basic client as confidential" do + untenanted do + post oauth_clients_path, params: { client_name: "Basic Connector", redirect_uris: %w[ https://connector.example.com/callback ], + token_endpoint_auth_method: "client_secret_basic" }, as: :json + end + + assert_response :created + assert_equal "client_secret_basic", response.parsed_body["token_endpoint_auth_method"] + assert_not_nil response.parsed_body["client_secret"] + assert Oauth::Client.find_by(client_id: response.parsed_body["client_id"]).confidential? + end + + private + def grant_for(client) + identities(:david).access_tokens.create!(oauth_client: client, permission: :read) + end + + def refresh(token, params: nil, headers: {}) + params ||= headers.key?("Authorization") ? {} : { client_id: token.oauth_client.client_id } + untenanted do + post oauth_token_path, params: { grant_type: "refresh_token", refresh_token: token.refresh_token }.merge(params), headers: headers + end + end + + def authorization_code_for(client) + Oauth::AuthorizationCode.generate \ + client_id: client.client_id, + identity_id: identities(:david).id, + code_challenge: Base64.urlsafe_encode64(Digest::SHA256.digest(CODE_VERIFIER), padding: false), + redirect_uri: "https://connector.example.com/callback", + scope: "read" + end +end diff --git a/test/integration/oauth_flow_test.rb b/test/integration/oauth_flow_test.rb index 8a1b1f14d3..b6c040296f 100644 --- a/test/integration/oauth_flow_test.rb +++ b/test/integration/oauth_flow_test.rb @@ -788,7 +788,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest }, as: :json end - assert_response :bad_request + assert_response :unauthorized assert_equal "invalid_client", response.parsed_body["error"] # Naming no client at all is a malformed request, whatever the code. @@ -827,7 +827,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest }, as: :json end - assert_response :bad_request + assert_response :unauthorized assert_equal "invalid_client", response.parsed_body["error"] end @@ -853,7 +853,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest }, as: :json end - assert_response :bad_request + assert_response :unauthorized assert_equal "invalid_client", response.parsed_body["error"] end @@ -907,7 +907,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest }, as: :json end - assert_response :bad_request + assert_response :unauthorized assert_equal "invalid_client", response.parsed_body["error"] end @@ -933,7 +933,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest }, as: :json end - assert_response :bad_request + assert_response :unauthorized assert_equal "invalid_client", response.parsed_body["error"] end @@ -949,7 +949,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest }, as: :json end - assert_response :bad_request + assert_response :unauthorized assert_equal "invalid_client", response.parsed_body["error"] untenanted do @@ -976,7 +976,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest untenanted { post oauth_token_path, params: exchange.merge(client_id), as: :json } end - assert_response :bad_request + assert_response :unauthorized assert_equal "invalid_client", response.parsed_body["error"] end end @@ -991,7 +991,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest [ { code_verifier: "not-the-verifier" }, { redirect_uri: "http://127.0.0.1:8888/elsewhere" } ].each do |mismatch| untenanted { post oauth_token_path, params: exchange.merge(mismatch), as: :json } - assert_response :bad_request + assert_response :unauthorized assert_equal "invalid_client", response.parsed_body["error"] end end @@ -1000,12 +1000,12 @@ class OauthFlowTest < ActionDispatch::IntegrationTest client = oauth_clients(:confidential_client) token = identities(:david).access_tokens.create!(oauth_client: client) - [ { grant_type: "password" }, - { grant_type: "refresh_token", refresh_token: token.refresh_token, client_id: client.client_id, client_secret: "wrong" }, - { grant_type: "refresh_token", refresh_token: "bogus", client_id: oauth_clients(:mcp_client).client_id } ].each do |request| + { { grant_type: "password" } => :bad_request, + { grant_type: "refresh_token", refresh_token: token.refresh_token, client_id: client.client_id, client_secret: "wrong" } => :unauthorized, + { grant_type: "refresh_token", refresh_token: "bogus", client_id: oauth_clients(:mcp_client).client_id } => :bad_request }.each do |request, status| untenanted { post oauth_token_path, params: request, as: :json } - assert_response :bad_request + assert_response status assert_equal "no-store", response.headers["Cache-Control"], request.inspect assert_equal "no-cache", response.headers["Pragma"] end @@ -1062,7 +1062,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest }, as: :json end - assert_response :bad_request + assert_response :unauthorized assert_equal "invalid_client", response.parsed_body["error"] end @@ -1445,8 +1445,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest assert_equal %w[ none ], body["revocation_endpoint_auth_methods_supported"] assert_includes body["grant_types_supported"], "authorization_code" assert_includes body["grant_types_supported"], "refresh_token" - assert_includes body["token_endpoint_auth_methods_supported"], "none" - assert_includes body["token_endpoint_auth_methods_supported"], "client_secret_post" + assert_equal %w[ none client_secret_post client_secret_basic ], body["token_endpoint_auth_methods_supported"] end test "protected resource metadata includes authorization server" do @@ -1743,9 +1742,9 @@ class OauthFlowTest < ActionDispatch::IntegrationTest assert_no_difference "Oauth::Client.count" do untenanted do post oauth_clients_path, params: { - client_name: "Basic Client", + client_name: "JWT Client", redirect_uris: [ "https://connector.example.com/callback" ], - token_endpoint_auth_method: "client_secret_basic" + token_endpoint_auth_method: "private_key_jwt" }, as: :json end end diff --git a/test/models/oauth/client_test.rb b/test/models/oauth/client_test.rb index 54d587cd03..c0961e62eb 100644 --- a/test/models/oauth/client_test.rb +++ b/test/models/oauth/client_test.rb @@ -255,11 +255,21 @@ class Oauth::ClientTest < ActiveSupport::TestCase assert_nil client.client_secret end + test "client_secret_basic clients are confidential" do + client = Oauth::Client.create!( + name: "Basic", + redirect_uris: %w[ https://connector.example.com/callback ], + token_endpoint_auth_method: "client_secret_basic" + ) + assert client.confidential? + assert_not_nil client.client_secret + end + test "token_endpoint_auth_method must be supported" do client = Oauth::Client.new( - name: "Basic", + name: "JWT", redirect_uris: %w[ http://127.0.0.1/cb ], - token_endpoint_auth_method: "client_secret_basic" + token_endpoint_auth_method: "private_key_jwt" ) assert_not client.valid? assert_includes client.errors[:token_endpoint_auth_method], "is not included in the list" diff --git a/test/test_helpers/oauth_client_credentials_test_helper.rb b/test/test_helpers/oauth_client_credentials_test_helper.rb new file mode 100644 index 0000000000..b413a9fb8b --- /dev/null +++ b/test/test_helpers/oauth_client_credentials_test_helper.rb @@ -0,0 +1,14 @@ +module OauthClientCredentialsTestHelper + # RFC 6749 §2.3.1 / Appendix B: each half is form-urlencoded, then joined + # with a colon and Basic-encoded. + def basic(client_id, secret) + encoded = [ client_id, secret ].map { |part| URI.encode_www_form_component(part) }.join(":") + { "Authorization" => "Basic #{Base64.strict_encode64(encoded)}" } + end + + def assert_client_authentication_failed(message = nil) + assert_response :unauthorized, message + assert_equal "invalid_client", response.parsed_body["error"], message + assert_equal %(Basic realm="http://www.example.com/"), response.headers["WWW-Authenticate"], message + end +end From 7f853cd7f2b7a33585da07b9e9c9fe02d1cdbd7e Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 6 Oct 2026 00:20:14 -0700 Subject: [PATCH 11/22] Authenticate clients at revocation and revoke only their own tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 7009 §2.1: the server first validates the client's credentials, then checks that the token was issued to that client. Before, any holder of a token could revoke the grant without the client's secret. A confidential client authenticates with client_secret_post or client_secret_basic. A public client names its client_id. Each revokes only tokens issued to it. If the token is unknown, belongs to another client or is a personal access token, the endpoint still answers 200 and revokes nothing (§2.2). It also answers 200 when no client is identified, as with a missing or unknown client_id. Only failed authentication is refused, with 401 invalid_client: a known confidential client with a missing or wrong secret, or a Basic header that doesn't authenticate. That answer depends only on the request, never on whether the token exists. Discovery advertises the three methods for revocation. Mirrors basecamp/bc3's Oauth::RevocationsController. --- app/controllers/oauth/metadata_controller.rb | 2 +- .../oauth/revocations_controller.rb | 35 +++-- test/integration/oauth_availability_test.rb | 2 +- test/integration/oauth_flow_test.rb | 20 +-- test/integration/oauth_revocation_test.rb | 130 ++++++++++++++++++ 5 files changed, 163 insertions(+), 26 deletions(-) create mode 100644 test/integration/oauth_revocation_test.rb diff --git a/app/controllers/oauth/metadata_controller.rb b/app/controllers/oauth/metadata_controller.rb index be587441bd..6a8104276d 100644 --- a/app/controllers/oauth/metadata_controller.rb +++ b/app/controllers/oauth/metadata_controller.rb @@ -10,7 +10,7 @@ def show token_endpoint: oauth_token_url, registration_endpoint: oauth_clients_url, revocation_endpoint: oauth_revocation_url, - revocation_endpoint_auth_methods_supported: %w[ none ], + revocation_endpoint_auth_methods_supported: Oauth::Client::AUTH_METHODS, response_types_supported: %w[ code ], response_modes_supported: %w[ query ], grant_types_supported: %w[ authorization_code refresh_token ], diff --git a/app/controllers/oauth/revocations_controller.rb b/app/controllers/oauth/revocations_controller.rb index 1d63849836..158753a37b 100644 --- a/app/controllers/oauth/revocations_controller.rb +++ b/app/controllers/oauth/revocations_controller.rb @@ -1,22 +1,41 @@ +# Token revocation (RFC 7009). The server validates the client's credentials +# and that the token was issued to it (§2.1), so a client revokes only its own +# tokens: a confidential client authenticates, and a public client names its +# client_id. Anything else that fails to identify a client, and any token that +# is unknown or belongs to another client, gets the same 200 as a successful +# revocation (§2.2), so the endpoint is no oracle for which tokens or clients +# exist. Only failed authentication is refused: a known confidential client +# with a missing or wrong secret, or a Basic header that doesn't authenticate. class Oauth::RevocationsController < Oauth::BaseController + include Oauth::ClientAuthentication + allow_unauthenticated_access skip_forgery_protection - before_action :set_access_token + before_action :reject_ambiguous_client_credentials + before_action :require_token def create - @access_token&.destroy + if client = authenticated_client + revocable_tokens(client).find_by(token: params[:token])&.destroy || + revocable_tokens(client).find_by(refresh_token: params[:token])&.destroy - head :ok # Don't behave as oracle, per RFC 7009 + head :ok + elsif client_secret_basic? || requesting_client&.confidential? + client_authentication_failed + else + head :ok + end end private - def set_access_token - if (token = params[:token]).present? - @access_token = Identity::AccessToken.find_by(token: token) || - Identity::AccessToken.find_by(refresh_token: token) - else + def require_token + unless params[:token].is_a?(String) && params[:token].present? oauth_error "invalid_request", "token is required" end end + + def revocable_tokens(client) + Identity::AccessToken.oauth.where(oauth_client: client) + end end diff --git a/test/integration/oauth_availability_test.rb b/test/integration/oauth_availability_test.rb index 73b3562390..9c81a8bf22 100644 --- a/test/integration/oauth_availability_test.rb +++ b/test/integration/oauth_availability_test.rb @@ -262,7 +262,7 @@ class OauthAvailabilityTest < ActionDispatch::IntegrationTest with_oauth_availability acceptance: false, issuance: false do assert_difference "Identity::AccessToken.count", -1 do - untenanted { post oauth_revocation_path, params: { token: token.token } } + untenanted { post oauth_revocation_path, params: { token: token.token, client_id: token.oauth_client.client_id } } end end diff --git a/test/integration/oauth_flow_test.rb b/test/integration/oauth_flow_test.rb index b6c040296f..86c7d2258b 100644 --- a/test/integration/oauth_flow_test.rb +++ b/test/integration/oauth_flow_test.rb @@ -1358,11 +1358,11 @@ class OauthFlowTest < ActionDispatch::IntegrationTest # Token Revocation (RFC 7009) test "revocation deletes access token" do - token = identity_access_tokens(:davids_api_token) + token = identities(:david).access_tokens.create!(oauth_client: oauth_clients(:mcp_client)) assert_difference "Identity::AccessToken.count", -1 do untenanted do - post oauth_revocation_path, params: { token: token.token }, as: :json + post oauth_revocation_path, params: { token: token.token, client_id: token.oauth_client.client_id }, as: :json end end @@ -1374,7 +1374,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest assert_difference "Identity::AccessToken.count", -1 do untenanted do - post oauth_revocation_path, params: { token: token.refresh_token }, as: :json + post oauth_revocation_path, params: { token: token.refresh_token, client_id: token.oauth_client.client_id }, as: :json end end @@ -1410,18 +1410,6 @@ class OauthFlowTest < ActionDispatch::IntegrationTest assert_equal "invalid_request", response.parsed_body["error"] end - test "revocation ignores client credentials and revokes on the token alone" do - access_token = identities(:david).access_tokens.create!(oauth_client: oauth_clients(:mcp_client), permission: :read) - - untenanted do - post oauth_revocation_path, params: { token: access_token.token }, - headers: { "Authorization" => ActionController::HttpAuthentication::Basic.encode_credentials("someone", "anything") } - end - - assert_response :success - assert_not Identity::AccessToken.exists?(access_token.id) - end - # Discovery Metadata (RFC 8414) @@ -1442,7 +1430,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest assert_equal true, body["authorization_response_iss_parameter_supported"] assert_equal %w[ query ], body["response_modes_supported"] assert_match %r{/oauth/revocation$}, body["revocation_endpoint"] - assert_equal %w[ none ], body["revocation_endpoint_auth_methods_supported"] + assert_equal %w[ none client_secret_post client_secret_basic ], body["revocation_endpoint_auth_methods_supported"] assert_includes body["grant_types_supported"], "authorization_code" assert_includes body["grant_types_supported"], "refresh_token" assert_equal %w[ none client_secret_post client_secret_basic ], body["token_endpoint_auth_methods_supported"] diff --git a/test/integration/oauth_revocation_test.rb b/test/integration/oauth_revocation_test.rb new file mode 100644 index 0000000000..f8289797e8 --- /dev/null +++ b/test/integration/oauth_revocation_test.rb @@ -0,0 +1,130 @@ +require "test_helper" + +# RFC 7009 §2.1: the server validates the client's credentials and that the +# token was issued to it. +class OauthRevocationTest < ActionDispatch::IntegrationTest + include OauthClientCredentialsTestHelper + + setup do + @client = oauth_clients(:confidential_client) + @secret = "confidential_secret_789" + end + + # Revocation (RFC 7009 §2.1) + + test "a confidential client must authenticate to revoke" do + token = grant_for(@client) + + [ { client_id: @client.client_id }, { client_id: @client.client_id, client_secret: "wrong" } ].each do |credentials| + revoke token.token, params: credentials + + assert_client_authentication_failed credentials.inspect + end + + assert Identity::AccessToken.exists?(token.id) + end + + test "a confidential client revokes its own token with client_secret_post or Basic" do + [ [ { client_id: @client.client_id, client_secret: @secret }, {} ], [ {}, basic(@client.client_id, @secret) ] ].each do |params, headers| + token = grant_for(@client) + + revoke token.refresh_token, params: params, headers: headers + + assert_response :success + assert_not Identity::AccessToken.exists?(token.id) + end + end + + test "an authenticated confidential client cannot revoke another client's token, and is told nothing" do + token = grant_for(oauth_clients(:mcp_client)) + + [ token.token, token.refresh_token ].each do |presented| + revoke presented, headers: basic(@client.client_id, @secret) + + assert_response :success + end + + assert Identity::AccessToken.exists?(token.id) + end + + test "a public client revokes its own token by naming its client_id" do + client = oauth_clients(:mcp_client) + + [ :token, :refresh_token ].each do |kind| + token = grant_for(client) + + revoke token.public_send(kind), params: { client_id: client.client_id } + + assert_response :success + assert_not Identity::AccessToken.exists?(token.id), kind + end + end + + test "a public client naming no client, or another client, revokes nothing and is told nothing" do + token = grant_for(oauth_clients(:mcp_client)) + + [ {}, { client_id: oauth_clients(:trusted_client).client_id }, { client_id: "no-such-client" } ].each do |params| + revoke token.token, params: params + + assert_response :success, params.inspect + end + + assert Identity::AccessToken.exists?(token.id) + end + + test "an unknown token answers 200" do + revoke "not-a-token", params: { client_id: oauth_clients(:mcp_client).client_id } + assert_response :success + + revoke "not-a-token", headers: basic(@client.client_id, @secret) + assert_response :success + end + + test "a personal access token is no client's to revoke" do + token = identity_access_tokens(:davids_api_token) + + revoke token.token, params: { client_id: oauth_clients(:mcp_client).client_id } + + assert_response :success + assert Identity::AccessToken.exists?(token.id) + end + + test "a Basic header that fails to authenticate is refused at revocation, whatever the token" do + token = grant_for(oauth_clients(:mcp_client)) + + [ basic("no-such-client", "x"), basic(@client.client_id, "wrong"), basic(oauth_clients(:mcp_client).client_id, "") ].each do |headers| + [ token.token, "not-a-token" ].each do |presented| + revoke presented, headers: headers + + assert_client_authentication_failed presented + end + end + + assert Identity::AccessToken.exists?(token.id) + end + + test "revocation refuses Basic beside a body client_secret" do + token = grant_for(@client) + + revoke token.token, params: { client_secret: @secret }, headers: basic(@client.client_id, @secret) + + assert_response :bad_request + assert_equal "invalid_request", response.parsed_body["error"] + assert Identity::AccessToken.exists?(token.id) + end + + test "metadata advertises client_secret_basic for revocation" do + untenanted { get "/.well-known/oauth-authorization-server" } + + assert_equal %w[ none client_secret_post client_secret_basic ], response.parsed_body["revocation_endpoint_auth_methods_supported"] + end + + private + def grant_for(client) + identities(:david).access_tokens.create!(oauth_client: client, permission: :read) + end + + def revoke(token, params: {}, headers: {}) + untenanted { post oauth_revocation_path, params: { token: token }.merge(params), headers: headers } + end +end From fde906124ddd0708fbee4df649200214f5a8039d Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 6 Oct 2026 00:38:37 -0700 Subject: [PATCH 12/22] Refuse a revocation whose client secret authenticates no client A body client_secret with no client_id, or with an unknown one, is a failed authentication attempt. Before this it got the 200 that means the token is dead, so a client with a mistyped client_id would stop using a grant that was still live. Revocation now uses the same rule as the token endpoint: Basic, a confidential client, or any secret is an attempt, and an attempt that fails gets 401. The rule turns on the request alone, so the refusal says nothing about which clients exist. --- .../concerns/oauth/client_authentication.rb | 8 ++++++++ app/controllers/oauth/revocations_controller.rb | 5 +++-- app/controllers/oauth/tokens_controller.rb | 4 ---- test/integration/oauth_revocation_test.rb | 14 ++++++++++++++ 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/app/controllers/concerns/oauth/client_authentication.rb b/app/controllers/concerns/oauth/client_authentication.rb index 9ea83b5813..6df75d908d 100644 --- a/app/controllers/concerns/oauth/client_authentication.rb +++ b/app/controllers/concerns/oauth/client_authentication.rb @@ -37,6 +37,14 @@ def authenticated_client end end + # A request attempts client authentication when it uses Basic, names a + # confidential client, or carries a client secret. This turns on what the + # request presents, so refusing a failed attempt reveals nothing about + # which clients exist. + def attempts_client_authentication? + client_secret_basic? || requesting_client&.confidential? || oauth_client_secret.present? + end + def requesting_client if oauth_client_id @requesting_client ||= Oauth::Client.find_by(client_id: oauth_client_id) diff --git a/app/controllers/oauth/revocations_controller.rb b/app/controllers/oauth/revocations_controller.rb index 158753a37b..779f020629 100644 --- a/app/controllers/oauth/revocations_controller.rb +++ b/app/controllers/oauth/revocations_controller.rb @@ -5,7 +5,8 @@ # is unknown or belongs to another client, gets the same 200 as a successful # revocation (§2.2), so the endpoint is no oracle for which tokens or clients # exist. Only failed authentication is refused: a known confidential client -# with a missing or wrong secret, or a Basic header that doesn't authenticate. +# with a missing or wrong secret, a Basic header that doesn't authenticate, +# or a client secret that authenticates no client. class Oauth::RevocationsController < Oauth::BaseController include Oauth::ClientAuthentication @@ -21,7 +22,7 @@ def create revocable_tokens(client).find_by(refresh_token: params[:token])&.destroy head :ok - elsif client_secret_basic? || requesting_client&.confidential? + elsif attempts_client_authentication? client_authentication_failed else head :ok diff --git a/app/controllers/oauth/tokens_controller.rb b/app/controllers/oauth/tokens_controller.rb index 788d6450a0..dcccfd8dfe 100644 --- a/app/controllers/oauth/tokens_controller.rb +++ b/app/controllers/oauth/tokens_controller.rb @@ -170,10 +170,6 @@ def authenticate_client end end - def attempts_client_authentication? - client_secret_basic? || requesting_client&.confidential? || oauth_client_secret.present? - end - def token_response(access_token, scope: nil) { access_token: access_token.token, diff --git a/test/integration/oauth_revocation_test.rb b/test/integration/oauth_revocation_test.rb index f8289797e8..80700a0412 100644 --- a/test/integration/oauth_revocation_test.rb +++ b/test/integration/oauth_revocation_test.rb @@ -103,6 +103,20 @@ class OauthRevocationTest < ActionDispatch::IntegrationTest assert Identity::AccessToken.exists?(token.id) end + test "a body client_secret that authenticates no client is refused, whatever the token" do + token = grant_for(@client) + + [ { client_secret: @secret }, { client_id: "no-such-client", client_secret: @secret } ].each do |params| + [ token.token, "not-a-token" ].each do |presented| + revoke presented, params: params + + assert_client_authentication_failed params.inspect + end + end + + assert Identity::AccessToken.exists?(token.id) + end + test "revocation refuses Basic beside a body client_secret" do token = grant_for(@client) From 11132dfee388f539f12fd47983772d850fd4efd1 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 6 Oct 2026 00:55:44 -0700 Subject: [PATCH 13/22] Refuse a client secret that names a public client at revocation A public client holds no password, so a body client_secret beside its client_id is a failed authentication attempt, the same as a Basic header naming it. Before this, the request identified the public client and got 200, even when the token belonged to another client and nothing was revoked. --- app/controllers/concerns/oauth/client_authentication.rb | 6 +++--- test/integration/oauth_revocation_test.rb | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/controllers/concerns/oauth/client_authentication.rb b/app/controllers/concerns/oauth/client_authentication.rb index 6df75d908d..f5653f8c34 100644 --- a/app/controllers/concerns/oauth/client_authentication.rb +++ b/app/controllers/concerns/oauth/client_authentication.rb @@ -29,11 +29,11 @@ def reject_ambiguous_client_credentials # The client the request authenticates as, or nil. A confidential client # authenticates with its secret. A public client identifies by client_id - # alone, but never through Basic: it holds no password, so a Basic header - # naming one is refused rather than downgraded to identification. + # alone, but never alongside a secret: it holds no password, so Basic or a + # body client_secret naming one is a failed attempt, not identification. def authenticated_client if client = requesting_client - client if client.confidential? ? client_secret_authenticates?(client) : !client_secret_basic? + client if client.confidential? ? client_secret_authenticates?(client) : !client_secret_basic? && oauth_client_secret.blank? end end diff --git a/test/integration/oauth_revocation_test.rb b/test/integration/oauth_revocation_test.rb index 80700a0412..f5d0a9c898 100644 --- a/test/integration/oauth_revocation_test.rb +++ b/test/integration/oauth_revocation_test.rb @@ -106,7 +106,8 @@ class OauthRevocationTest < ActionDispatch::IntegrationTest test "a body client_secret that authenticates no client is refused, whatever the token" do token = grant_for(@client) - [ { client_secret: @secret }, { client_id: "no-such-client", client_secret: @secret } ].each do |params| + [ { client_secret: @secret }, { client_id: "no-such-client", client_secret: @secret }, + { client_id: oauth_clients(:mcp_client).client_id, client_secret: "anything" } ].each do |params| [ token.token, "not-a-token" ].each do |presented| revoke presented, params: params From 9e107d80e83576673004810f703b35b305bcd03d Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 6 Oct 2026 03:11:40 -0700 Subject: [PATCH 14/22] Name the Basic scheme only to a client that tried the Authorization header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 6749 §5.2 requires the WWW-Authenticate challenge on invalid_client when the client attempted authentication through the Authorization header. A client that sent its secret in the body never tried the header, so its 401 now carries no challenge, at the token endpoint and at revocation alike. Any Authorization header still earns one, since a 401 must name a scheme that applies and Basic is the only one here. Status codes are unchanged: invalid_client is 401 either way. This matches basecamp/bc3 (Oauth::ClientAuthentication#challenge_client_authentication). --- .../concerns/oauth/client_authentication.rb | 15 +++++++++------ .../oauth_client_secret_basic_test.rb | 16 +++++++++++++--- test/integration/oauth_revocation_test.rb | 4 ++-- .../oauth_client_credentials_test_helper.rb | 11 +++++++++-- 4 files changed, 33 insertions(+), 13 deletions(-) diff --git a/app/controllers/concerns/oauth/client_authentication.rb b/app/controllers/concerns/oauth/client_authentication.rb index f5653f8c34..cdefb2dd21 100644 --- a/app/controllers/concerns/oauth/client_authentication.rb +++ b/app/controllers/concerns/oauth/client_authentication.rb @@ -56,13 +56,16 @@ def client_secret_authenticates?(client) client.authenticate_secret(oauth_client_secret) end - # RFC 6749 §5.2: invalid_client may answer 401 to name the schemes the - # server supports, and must when the client tried the Authorization - # header. Basic is the one we support, so every failure names it, and - # the answer is the same for an unknown client and a wrong secret. - # RFC 7617 defines no error parameter for Basic, only realm. + # RFC 6749 §5.2: invalid_client is a 401, and a client that tried the + # Authorization header gets a WWW-Authenticate naming the scheme. Basic is + # the only header-borne client authentication, so the challenge turns on + # the request's own header alone: identical for an unknown client and a + # wrong secret, and absent for a client that authenticated in the body. + # Any scheme counts, not just Basic: a 401 must carry a challenge that + # applies (RFC 9110 §15.5.2), and Basic is the only one here. RFC 7617 + # defines no error parameter for Basic, only realm. Mirrors basecamp/bc3. def client_authentication_failed - response.headers["WWW-Authenticate"] = %(Basic realm="#{oauth_issuer}") + response.headers["WWW-Authenticate"] = %(Basic realm="#{oauth_issuer}") if request.authorization.present? oauth_error "invalid_client", "Client authentication failed", status: :unauthorized end diff --git a/test/integration/oauth_client_secret_basic_test.rb b/test/integration/oauth_client_secret_basic_test.rb index 29d12760be..b772df0009 100644 --- a/test/integration/oauth_client_secret_basic_test.rb +++ b/test/integration/oauth_client_secret_basic_test.rb @@ -125,13 +125,23 @@ class OauthClientSecretBasicTest < ActionDispatch::IntegrationTest assert_equal token.refresh_token, token.reload.refresh_token end - test "a failed client_secret_post authentication is a 401 that names the Basic scheme" do + test "a failed client_secret_post authentication is a 401 with no challenge, since the client never tried the header" do token = grant_for(@client) - refresh token, params: { client_id: @client.client_id, client_secret: "wrong" } + [ { client_id: @client.client_id, client_secret: "wrong" }, { client_id: @client.client_id } ].each do |params| + refresh token, params: params + + assert_client_authentication_failed params.inspect, challenged: false + assert_equal "no-store", response.headers["Cache-Control"] + end + end + + test "a failed client_secret_post beside an Authorization header of another scheme names Basic" do + token = grant_for(@client) + + refresh token, params: { client_id: @client.client_id, client_secret: "wrong" }, headers: { "Authorization" => "Bearer #{token.token}" } assert_client_authentication_failed - assert_equal "no-store", response.headers["Cache-Control"] end test "the pilot gate reads the client a Basic header names" do diff --git a/test/integration/oauth_revocation_test.rb b/test/integration/oauth_revocation_test.rb index f5d0a9c898..0010ff3f6a 100644 --- a/test/integration/oauth_revocation_test.rb +++ b/test/integration/oauth_revocation_test.rb @@ -18,7 +18,7 @@ class OauthRevocationTest < ActionDispatch::IntegrationTest [ { client_id: @client.client_id }, { client_id: @client.client_id, client_secret: "wrong" } ].each do |credentials| revoke token.token, params: credentials - assert_client_authentication_failed credentials.inspect + assert_client_authentication_failed credentials.inspect, challenged: false end assert Identity::AccessToken.exists?(token.id) @@ -111,7 +111,7 @@ class OauthRevocationTest < ActionDispatch::IntegrationTest [ token.token, "not-a-token" ].each do |presented| revoke presented, params: params - assert_client_authentication_failed params.inspect + assert_client_authentication_failed params.inspect, challenged: false end end diff --git a/test/test_helpers/oauth_client_credentials_test_helper.rb b/test/test_helpers/oauth_client_credentials_test_helper.rb index b413a9fb8b..beb0f32bf3 100644 --- a/test/test_helpers/oauth_client_credentials_test_helper.rb +++ b/test/test_helpers/oauth_client_credentials_test_helper.rb @@ -6,9 +6,16 @@ def basic(client_id, secret) { "Authorization" => "Basic #{Base64.strict_encode64(encoded)}" } end - def assert_client_authentication_failed(message = nil) + # RFC 6749 §5.2: invalid_client is a 401, and it names the Basic scheme + # only to a client that tried the Authorization header. + def assert_client_authentication_failed(message = nil, challenged: true) assert_response :unauthorized, message assert_equal "invalid_client", response.parsed_body["error"], message - assert_equal %(Basic realm="http://www.example.com/"), response.headers["WWW-Authenticate"], message + + if challenged + assert_equal %(Basic realm="http://www.example.com/"), response.headers["WWW-Authenticate"], message + else + assert_nil response.headers["WWW-Authenticate"], message + end end end From cbb2dd9ca633153cd26a48a76cabe75e17de178b Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 6 Oct 2026 03:12:44 -0700 Subject: [PATCH 15/22] Revoke personal access tokens at the revocation endpoint again Restricting revocation to a client's own tokens also stopped personal access tokens from being revoked there, since they belong to no client. Revocation should work the same for every kind of token, and personal access tokens may later become OAuth tokens themselves, as they are in Basecamp. A personal access token has no client, so holding it is the credential: whoever presents one revokes it. Client credentials sent along with it still have to authenticate under the usual rules. A failed attempt gets 401 invalid_client and revokes nothing, and Basic beside a body secret is still invalid_request. OAuth tokens are unchanged: a client revokes only its own. --- .../oauth/revocations_controller.rb | 24 ++++--- test/integration/oauth_revocation_test.rb | 62 +++++++++++++++++-- 2 files changed, 74 insertions(+), 12 deletions(-) diff --git a/app/controllers/oauth/revocations_controller.rb b/app/controllers/oauth/revocations_controller.rb index 779f020629..4a315a94a0 100644 --- a/app/controllers/oauth/revocations_controller.rb +++ b/app/controllers/oauth/revocations_controller.rb @@ -7,6 +7,10 @@ # exist. Only failed authentication is refused: a known confidential client # with a missing or wrong secret, a Basic header that doesn't authenticate, # or a client secret that authenticates no client. +# +# A personal access token was issued to no client, so possession of it is the +# credential: whoever presents one revokes it, client or not. Client +# credentials presented beside it must still authenticate, by the same rules. class Oauth::RevocationsController < Oauth::BaseController include Oauth::ClientAuthentication @@ -17,14 +21,11 @@ class Oauth::RevocationsController < Oauth::BaseController before_action :require_token def create - if client = authenticated_client - revocable_tokens(client).find_by(token: params[:token])&.destroy || - revocable_tokens(client).find_by(refresh_token: params[:token])&.destroy - - head :ok - elsif attempts_client_authentication? + if attempts_client_authentication? && !authenticated_client client_authentication_failed else + revocable_token&.destroy + head :ok end end @@ -36,7 +37,14 @@ def require_token end end - def revocable_tokens(client) - Identity::AccessToken.oauth.where(oauth_client: client) + def revocable_token + Identity::AccessToken.personal.find_by(token: params[:token]) || client_token + end + + def client_token + if client = authenticated_client + tokens = Identity::AccessToken.oauth.where(oauth_client: client) + tokens.find_by(token: params[:token]) || tokens.find_by(refresh_token: params[:token]) + end end end diff --git a/test/integration/oauth_revocation_test.rb b/test/integration/oauth_revocation_test.rb index 0010ff3f6a..de018f0da5 100644 --- a/test/integration/oauth_revocation_test.rb +++ b/test/integration/oauth_revocation_test.rb @@ -80,15 +80,65 @@ class OauthRevocationTest < ActionDispatch::IntegrationTest assert_response :success end - test "a personal access token is no client's to revoke" do - token = identity_access_tokens(:davids_api_token) + # A personal access token has no client, so possession is the credential: + # whoever holds one may revoke it, the same as signing out of it. - revoke token.token, params: { client_id: oauth_clients(:mcp_client).client_id } + test "a personal access token is revoked by whoever presents it" do + [ {}, { client_id: oauth_clients(:mcp_client).client_id }, { client_id: "no-such-client" } ].each do |params| + token = personal_token - assert_response :success + revoke token.token, params: params + + assert_response :success, params.inspect + assert_not Identity::AccessToken.exists?(token.id), params.inspect + end + end + + test "a personal access token is revoked beside client credentials that authenticate" do + [ [ { client_id: @client.client_id, client_secret: @secret }, {} ], [ {}, basic(@client.client_id, @secret) ] ].each do |params, headers| + token = personal_token + + revoke token.token, params: params, headers: headers + + assert_response :success + assert_not Identity::AccessToken.exists?(token.id) + end + end + + test "client credentials that fail to authenticate are refused before a personal access token is revoked" do + token = personal_token + + [ [ { client_id: @client.client_id }, {}, false ], [ { client_id: @client.client_id, client_secret: "wrong" }, {}, false ], + [ { client_secret: @secret }, {}, false ], [ { client_id: oauth_clients(:mcp_client).client_id, client_secret: "anything" }, {}, false ], + [ {}, basic(@client.client_id, "wrong"), true ], [ {}, basic("no-such-client", "x"), true ] ].each do |params, headers, challenged| + revoke token.token, params: params, headers: headers + + assert_client_authentication_failed [ params, headers ].inspect, challenged: challenged + end + + assert Identity::AccessToken.exists?(token.id) + end + + test "a personal access token is not revoked by two client authentication methods at once" do + token = personal_token + + revoke token.token, params: { client_secret: @secret }, headers: basic(@client.client_id, @secret) + + assert_response :bad_request assert Identity::AccessToken.exists?(token.id) end + test "a revoked personal access token no longer authenticates" do + token = personal_token + get user_path(users(:david)), env: { "HTTP_AUTHORIZATION" => "Bearer #{token.token}" }, as: :json + assert_response :success + + revoke token.token + + get user_path(users(:david)), env: { "HTTP_AUTHORIZATION" => "Bearer #{token.token}" }, as: :json + assert_response :unauthorized + end + test "a Basic header that fails to authenticate is refused at revocation, whatever the token" do token = grant_for(oauth_clients(:mcp_client)) @@ -135,6 +185,10 @@ class OauthRevocationTest < ActionDispatch::IntegrationTest end private + def personal_token + identities(:david).access_tokens.create!(permission: :read) + end + def grant_for(client) identities(:david).access_tokens.create!(oauth_client: client, permission: :read) end From 6fec38dee9d8daa78ceb623767e25780ab2fcc80 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 6 Oct 2026 03:47:30 -0700 Subject: [PATCH 16/22] Answer a grant issued to another client exactly as a dead one A request naming a public client makes no authentication attempt. Presenting a confidential client's code or refresh token in that request got "Grant was not issued to this client" when the grant was live, and "Invalid refresh token" when it wasn't. The description was an oracle for whether someone else's grant was live. Both cases now give the dead-grant answer. --- app/controllers/oauth/tokens_controller.rb | 4 +++- test/integration/oauth_flow_test.rb | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/app/controllers/oauth/tokens_controller.rb b/app/controllers/oauth/tokens_controller.rb index dcccfd8dfe..e07468ab88 100644 --- a/app/controllers/oauth/tokens_controller.rb +++ b/app/controllers/oauth/tokens_controller.rb @@ -127,9 +127,11 @@ def set_refreshable_access_token # The code or refresh token must have been issued to the client_id in the # request (RFC 6749 §4.1.3, §6). + # One issued to another client gets the same answer as a dead one, so + # naming a public client tells nobody whether someone else's grant is live. def validate_client_id unless oauth_client_id == (@client || @access_token.oauth_client).client_id - oauth_error "invalid_grant", "Grant was not issued to this client" + oauth_error "invalid_grant", authorization_code_grant? ? "Invalid or expired authorization code" : "Invalid refresh token" end end diff --git a/test/integration/oauth_flow_test.rb b/test/integration/oauth_flow_test.rb index 86c7d2258b..54b57834a1 100644 --- a/test/integration/oauth_flow_test.rb +++ b/test/integration/oauth_flow_test.rb @@ -1050,6 +1050,28 @@ class OauthFlowTest < ActionDispatch::IntegrationTest end end + test "a grant issued to another client answers exactly as a dead one does" do + live = identities(:david).access_tokens.create!(oauth_client: oauth_clients(:confidential_client)) + answers = [ live.refresh_token, "no-such-refresh-token" ].map do |refresh_token| + untenanted do + post oauth_token_path, params: { grant_type: "refresh_token", refresh_token: refresh_token, client_id: oauth_clients(:mcp_client).client_id } + end + [ response.status, response.parsed_body ] + end + assert_equal answers.first, answers.last + + code = Oauth::AuthorizationCode.generate client_id: oauth_clients(:confidential_client).client_id, identity_id: identities(:david).id, + code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", redirect_uri: "https://connector.example.com/callback", scope: "read" + answers = [ code, "no-such-code" ].map do |presented| + untenanted do + post oauth_token_path, params: { grant_type: "authorization_code", code: presented, client_id: oauth_clients(:mcp_client).client_id, + redirect_uri: "https://connector.example.com/callback", code_verifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" } + end + [ response.status, response.parsed_body ] + end + assert_equal answers.first, answers.last + end + test "refresh grant for confidential client omitting client_id fails as invalid_client" do client = oauth_clients(:confidential_client) token = identities(:david).access_tokens.create!(oauth_client: client) From 9888beed7db9806f99cdc799dd5482995d728d58 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 6 Oct 2026 04:36:16 -0700 Subject: [PATCH 17/22] Throttle revocation like the token endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revocation now authenticates confidential clients: a wrong secret gets 401, the right one gets 200. Unthrottled, that let anyone who knew a public client_id guess its secret without limit, around the token endpoint's limit of 20 per minute. Revocation now has the same per-IP limit (RFC 6749 §2.3.1, RFC 7009 §5). --- app/controllers/oauth/revocations_controller.rb | 4 ++++ test/integration/oauth_revocation_test.rb | 11 +++++++++++ 2 files changed, 15 insertions(+) diff --git a/app/controllers/oauth/revocations_controller.rb b/app/controllers/oauth/revocations_controller.rb index 4a315a94a0..b41b486691 100644 --- a/app/controllers/oauth/revocations_controller.rb +++ b/app/controllers/oauth/revocations_controller.rb @@ -17,6 +17,10 @@ class Oauth::RevocationsController < Oauth::BaseController allow_unauthenticated_access skip_forgery_protection + # Client authentication happens here too, so secrets can be guessed here: + # throttled like the token endpoint (RFC 6749 §2.3.1, RFC 7009 §5). + rate_limit to: 20, within: 1.minute, only: :create, with: :oauth_rate_limit_exceeded + before_action :reject_ambiguous_client_credentials before_action :require_token diff --git a/test/integration/oauth_revocation_test.rb b/test/integration/oauth_revocation_test.rb index de018f0da5..53178565ca 100644 --- a/test/integration/oauth_revocation_test.rb +++ b/test/integration/oauth_revocation_test.rb @@ -178,6 +178,17 @@ class OauthRevocationTest < ActionDispatch::IntegrationTest assert Identity::AccessToken.exists?(token.id) end + # Revocation authenticates clients, so it is a place to guess secrets: it is + # throttled like the token endpoint (RFC 6749 §2.3.1, RFC 7009 §5). + test "revocation is rate limited like the token endpoint" do + ActiveSupport::Cache::NullStore.any_instance.stubs(:increment).returns(21) + + revoke "not-a-token", params: { client_id: @client.client_id, client_secret: "guess" } + + assert_response :too_many_requests + assert_equal "slow_down", response.parsed_body["error"] + end + test "metadata advertises client_secret_basic for revocation" do untenanted { get "/.well-known/oauth-authorization-server" } From 1688454f1f0fa67ff2283e76cffcde362fcd0c18 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 6 Oct 2026 10:43:06 -0700 Subject: [PATCH 18/22] Answer invalid_client with 400 unless Basic authentication failed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failed Basic authentication stays a 401 challenging Basic: the endpoint evaluated the Authorization header, so the challenge applies. Every other invalid_client is now a 400 with no challenge: a failed client_secret_post, a public client presenting a secret, a secret that authenticates no client at revocation, or a body failure beside an Authorization header of another scheme, which used to draw a Basic challenge for a scheme never tried. - A bare 401 violates RFC 9110 §15.5.2, which requires a challenge. - A Basic challenge for a request that never used Basic names a scheme that doesn't apply to what was tried. - oauth4webapi and openid-client turn a challenged 401 into a WWWAuthenticateChallengeError instead of invalid_client. - A 401 challenging Basic on a form POST can pop a browser's auth dialog. Revocation keeps its stricter rule, refusing a presented secret that authenticates no client, now with 400. --- .../concerns/oauth/client_authentication.rb | 27 ++++++++++------ test/integration/oauth_availability_test.rb | 4 +-- .../oauth_client_secret_basic_test.rb | 8 ++--- test/integration/oauth_flow_test.rb | 31 +++++++------------ test/integration/oauth_revocation_test.rb | 17 +++++++--- .../oauth_client_credentials_test_helper.rb | 11 ++++--- 6 files changed, 54 insertions(+), 44 deletions(-) diff --git a/app/controllers/concerns/oauth/client_authentication.rb b/app/controllers/concerns/oauth/client_authentication.rb index cdefb2dd21..57701777c6 100644 --- a/app/controllers/concerns/oauth/client_authentication.rb +++ b/app/controllers/concerns/oauth/client_authentication.rb @@ -56,17 +56,24 @@ def client_secret_authenticates?(client) client.authenticate_secret(oauth_client_secret) end - # RFC 6749 §5.2: invalid_client is a 401, and a client that tried the - # Authorization header gets a WWW-Authenticate naming the scheme. Basic is - # the only header-borne client authentication, so the challenge turns on - # the request's own header alone: identical for an unknown client and a - # wrong secret, and absent for a client that authenticated in the body. - # Any scheme counts, not just Basic: a 401 must carry a challenge that - # applies (RFC 9110 §15.5.2), and Basic is the only one here. RFC 7617 - # defines no error parameter for Basic, only realm. Mirrors basecamp/bc3. + # A failed Basic authentication is a 401 challenging Basic (RFC 6749 §5.2): + # the endpoint evaluated the Authorization header, so the challenge applies + # (RFC 9110 §15.5.2). Every other failure is a 400 with no challenge: a + # body credential, a public client presenting a secret, or a secret that + # authenticates no client. A bare 401 would break RFC 9110, a challenge for + # a scheme the client never tried applies to nothing, oauth4webapi and + # openid-client surface a challenged 401 as a WWWAuthenticateChallengeError + # rather than invalid_client, and a Basic challenge on a form POST can pop + # a browser's password dialog. Either way the answer turns on the request's + # own header alone, so an unknown client and a wrong secret look the same. + # RFC 7617 defines no error parameter for Basic, only realm. def client_authentication_failed - response.headers["WWW-Authenticate"] = %(Basic realm="#{oauth_issuer}") if request.authorization.present? - oauth_error "invalid_client", "Client authentication failed", status: :unauthorized + if client_secret_basic? + response.headers["WWW-Authenticate"] = %(Basic realm="#{oauth_issuer}") + oauth_error "invalid_client", "Client authentication failed", status: :unauthorized + else + oauth_error "invalid_client", "Client authentication failed" + end end # Whether the request used the Basic scheme, well-formed or not. diff --git a/test/integration/oauth_availability_test.rb b/test/integration/oauth_availability_test.rb index 9c81a8bf22..6a2be65dae 100644 --- a/test/integration/oauth_availability_test.rb +++ b/test/integration/oauth_availability_test.rb @@ -2,6 +2,7 @@ class OauthAvailabilityTest < ActionDispatch::IntegrationTest include OauthAvailabilityTestHelper + include OauthClientCredentialsTestHelper CODE_VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" @@ -220,8 +221,7 @@ class OauthAvailabilityTest < ActionDispatch::IntegrationTest untenanted { post oauth_token_path, params: refresh_params(token).merge(client_secret: "wrong") } end - assert_response :unauthorized - assert_equal "invalid_client", response.parsed_body["error"] + assert_client_authentication_failed basic: false assert_equal token.refresh_token, token.reload.refresh_token end diff --git a/test/integration/oauth_client_secret_basic_test.rb b/test/integration/oauth_client_secret_basic_test.rb index b772df0009..5b3a1d759c 100644 --- a/test/integration/oauth_client_secret_basic_test.rb +++ b/test/integration/oauth_client_secret_basic_test.rb @@ -125,23 +125,23 @@ class OauthClientSecretBasicTest < ActionDispatch::IntegrationTest assert_equal token.refresh_token, token.reload.refresh_token end - test "a failed client_secret_post authentication is a 401 with no challenge, since the client never tried the header" do + test "a failed client_secret_post authentication is a 400 with no challenge, since the client never tried the header" do token = grant_for(@client) [ { client_id: @client.client_id, client_secret: "wrong" }, { client_id: @client.client_id } ].each do |params| refresh token, params: params - assert_client_authentication_failed params.inspect, challenged: false + assert_client_authentication_failed params.inspect, basic: false assert_equal "no-store", response.headers["Cache-Control"] end end - test "a failed client_secret_post beside an Authorization header of another scheme names Basic" do + test "a failed client_secret_post beside an Authorization header of another scheme is a 400: Basic was never evaluated" do token = grant_for(@client) refresh token, params: { client_id: @client.client_id, client_secret: "wrong" }, headers: { "Authorization" => "Bearer #{token.token}" } - assert_client_authentication_failed + assert_client_authentication_failed basic: false end test "the pilot gate reads the client a Basic header names" do diff --git a/test/integration/oauth_flow_test.rb b/test/integration/oauth_flow_test.rb index 54b57834a1..ac7dafd557 100644 --- a/test/integration/oauth_flow_test.rb +++ b/test/integration/oauth_flow_test.rb @@ -1,6 +1,8 @@ require "test_helper" class OauthFlowTest < ActionDispatch::IntegrationTest + include OauthClientCredentialsTestHelper + # Authorization Endpoint test "authorization requires authentication" do @@ -788,8 +790,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest }, as: :json end - assert_response :unauthorized - assert_equal "invalid_client", response.parsed_body["error"] + assert_client_authentication_failed basic: false # Naming no client at all is a malformed request, whatever the code. untenanted do @@ -827,8 +828,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest }, as: :json end - assert_response :unauthorized - assert_equal "invalid_client", response.parsed_body["error"] + assert_client_authentication_failed basic: false end test "token exchange for confidential client rejects a non-string client secret" do @@ -853,8 +853,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest }, as: :json end - assert_response :unauthorized - assert_equal "invalid_client", response.parsed_body["error"] + assert_client_authentication_failed basic: false end test "token exchange for confidential client succeeds with the client secret" do @@ -907,8 +906,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest }, as: :json end - assert_response :unauthorized - assert_equal "invalid_client", response.parsed_body["error"] + assert_client_authentication_failed basic: false end test "token exchange for confidential client requires a matching client_id" do @@ -933,8 +931,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest }, as: :json end - assert_response :unauthorized - assert_equal "invalid_client", response.parsed_body["error"] + assert_client_authentication_failed basic: false end test "refresh grant for confidential client requires the client secret" do @@ -949,8 +946,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest }, as: :json end - assert_response :unauthorized - assert_equal "invalid_client", response.parsed_body["error"] + assert_client_authentication_failed basic: false untenanted do post oauth_token_path, params: { @@ -976,8 +972,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest untenanted { post oauth_token_path, params: exchange.merge(client_id), as: :json } end - assert_response :unauthorized - assert_equal "invalid_client", response.parsed_body["error"] + assert_client_authentication_failed basic: false end end @@ -991,8 +986,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest [ { code_verifier: "not-the-verifier" }, { redirect_uri: "http://127.0.0.1:8888/elsewhere" } ].each do |mismatch| untenanted { post oauth_token_path, params: exchange.merge(mismatch), as: :json } - assert_response :unauthorized - assert_equal "invalid_client", response.parsed_body["error"] + assert_client_authentication_failed basic: false end end @@ -1001,7 +995,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest token = identities(:david).access_tokens.create!(oauth_client: client) { { grant_type: "password" } => :bad_request, - { grant_type: "refresh_token", refresh_token: token.refresh_token, client_id: client.client_id, client_secret: "wrong" } => :unauthorized, + { grant_type: "refresh_token", refresh_token: token.refresh_token, client_id: client.client_id, client_secret: "wrong" } => :bad_request, { grant_type: "refresh_token", refresh_token: "bogus", client_id: oauth_clients(:mcp_client).client_id } => :bad_request }.each do |request, status| untenanted { post oauth_token_path, params: request, as: :json } @@ -1084,8 +1078,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest }, as: :json end - assert_response :unauthorized - assert_equal "invalid_client", response.parsed_body["error"] + assert_client_authentication_failed basic: false end diff --git a/test/integration/oauth_revocation_test.rb b/test/integration/oauth_revocation_test.rb index 53178565ca..34af3825a0 100644 --- a/test/integration/oauth_revocation_test.rb +++ b/test/integration/oauth_revocation_test.rb @@ -18,7 +18,7 @@ class OauthRevocationTest < ActionDispatch::IntegrationTest [ { client_id: @client.client_id }, { client_id: @client.client_id, client_secret: "wrong" } ].each do |credentials| revoke token.token, params: credentials - assert_client_authentication_failed credentials.inspect, challenged: false + assert_client_authentication_failed credentials.inspect, basic: false end assert Identity::AccessToken.exists?(token.id) @@ -110,10 +110,10 @@ class OauthRevocationTest < ActionDispatch::IntegrationTest [ [ { client_id: @client.client_id }, {}, false ], [ { client_id: @client.client_id, client_secret: "wrong" }, {}, false ], [ { client_secret: @secret }, {}, false ], [ { client_id: oauth_clients(:mcp_client).client_id, client_secret: "anything" }, {}, false ], - [ {}, basic(@client.client_id, "wrong"), true ], [ {}, basic("no-such-client", "x"), true ] ].each do |params, headers, challenged| + [ {}, basic(@client.client_id, "wrong"), true ], [ {}, basic("no-such-client", "x"), true ] ].each do |params, headers, via_basic| revoke token.token, params: params, headers: headers - assert_client_authentication_failed [ params, headers ].inspect, challenged: challenged + assert_client_authentication_failed [ params, headers ].inspect, basic: via_basic end assert Identity::AccessToken.exists?(token.id) @@ -161,13 +161,22 @@ class OauthRevocationTest < ActionDispatch::IntegrationTest [ token.token, "not-a-token" ].each do |presented| revoke presented, params: params - assert_client_authentication_failed params.inspect, challenged: false + assert_client_authentication_failed params.inspect, basic: false end end assert Identity::AccessToken.exists?(token.id) end + test "a failed body authentication beside an Authorization header of another scheme is a 400: Basic was never evaluated" do + token = grant_for(@client) + + revoke token.token, params: { client_id: @client.client_id, client_secret: "wrong" }, headers: { "Authorization" => "Bearer #{token.token}" } + + assert_client_authentication_failed basic: false + assert Identity::AccessToken.exists?(token.id) + end + test "revocation refuses Basic beside a body client_secret" do token = grant_for(@client) diff --git a/test/test_helpers/oauth_client_credentials_test_helper.rb b/test/test_helpers/oauth_client_credentials_test_helper.rb index beb0f32bf3..fa89adb51f 100644 --- a/test/test_helpers/oauth_client_credentials_test_helper.rb +++ b/test/test_helpers/oauth_client_credentials_test_helper.rb @@ -6,13 +6,14 @@ def basic(client_id, secret) { "Authorization" => "Basic #{Base64.strict_encode64(encoded)}" } end - # RFC 6749 §5.2: invalid_client is a 401, and it names the Basic scheme - # only to a client that tried the Authorization header. - def assert_client_authentication_failed(message = nil, challenged: true) - assert_response :unauthorized, message + # A failed Basic authentication is a 401 challenging Basic (RFC 6749 §5.2, + # RFC 9110 §15.5.2). Any other failure is a 400 with no challenge: there is + # no header-borne scheme it could have tried and should retry. + def assert_client_authentication_failed(message = nil, basic: true) + assert_response basic ? :unauthorized : :bad_request, message assert_equal "invalid_client", response.parsed_body["error"], message - if challenged + if basic assert_equal %(Basic realm="http://www.example.com/"), response.headers["WWW-Authenticate"], message else assert_nil response.headers["WWW-Authenticate"], message From 1e12953564e8197414c1f0aaff2e43280ba40e3e Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 16 Oct 2026 23:55:08 -0700 Subject: [PATCH 19/22] Keep every authorization server response out of caches and Referers no-store covered the token and registration endpoints. Discovery, consent, its redirects and revocation answered without it, and every response sent the app's default Referrer-Policy. Now each OAuth response, halted callback chains and the sign-in redirect included, carries Cache-Control: no-store and Referrer-Policy: no-referrer. --- app/controllers/oauth/base_controller.rb | 12 +++ app/controllers/oauth/clients_controller.rb | 3 - app/controllers/oauth/tokens_controller.rb | 4 - .../oauth_response_headers_test.rb | 81 +++++++++++++++++++ 4 files changed, 93 insertions(+), 7 deletions(-) create mode 100644 test/integration/oauth_response_headers_test.rb diff --git a/app/controllers/oauth/base_controller.rb b/app/controllers/oauth/base_controller.rb index ef43a8dd67..f293ac3274 100644 --- a/app/controllers/oauth/base_controller.rb +++ b/app/controllers/oauth/base_controller.rb @@ -1,6 +1,14 @@ class Oauth::BaseController < ApplicationController disallow_account_scope + # Every authorization server response, errors, consent and sign-in + # redirects included, is uncacheable and sends no Referer, so a code, a + # token, a secret or an authorization request's parameters reach neither a + # shared cache nor the page a browser goes to next. Prepended, so it runs + # ahead of every callback that can halt, the app's authentication included: + # a halted chain skips after_actions (RFC 6749 §5.1, §5.2). + prepend_before_action :prevent_caching, :prevent_referrer + private # Discovery follows acceptance (see Oauth::Availability): a dark server # answers as if it had no authorization server at all. @@ -37,6 +45,10 @@ def prevent_caching response.headers["Pragma"] = "no-cache" end + def prevent_referrer + response.headers["Referrer-Policy"] = "no-referrer" + end + def oauth_error(error, description = nil, status: :bad_request) render json: { error: error, error_description: description }.compact, status: status end diff --git a/app/controllers/oauth/clients_controller.rb b/app/controllers/oauth/clients_controller.rb index b8fae0c9c3..1d5b1c1b01 100644 --- a/app/controllers/oauth/clients_controller.rb +++ b/app/controllers/oauth/clients_controller.rb @@ -2,9 +2,6 @@ class Oauth::ClientsController < Oauth::BaseController allow_unauthenticated_access skip_forgery_protection - # A before_action so error responses are not cached either. - before_action :prevent_caching - rate_limit to: 10, within: 1.minute, only: :create, with: :oauth_rate_limit_exceeded before_action :require_issuance_enabled diff --git a/app/controllers/oauth/tokens_controller.rb b/app/controllers/oauth/tokens_controller.rb index e07468ab88..e759956af9 100644 --- a/app/controllers/oauth/tokens_controller.rb +++ b/app/controllers/oauth/tokens_controller.rb @@ -4,10 +4,6 @@ class Oauth::TokensController < Oauth::BaseController allow_unauthenticated_access skip_forgery_protection - # A before_action, not after: a halted callback chain skips after_actions, - # and error responses must not be cached either (RFC 6749 §5.1, §5.2). - before_action :prevent_caching - rate_limit to: 20, within: 1.minute, only: :create, with: :oauth_rate_limit_exceeded before_action :require_issuance_enabled diff --git a/test/integration/oauth_response_headers_test.rb b/test/integration/oauth_response_headers_test.rb new file mode 100644 index 0000000000..335e7fa3d9 --- /dev/null +++ b/test/integration/oauth_response_headers_test.rb @@ -0,0 +1,81 @@ +require "test_helper" + +# Every authorization server response is uncacheable and sends no Referer, +# whether it succeeds, fails, or never reaches its action. +class OauthResponseHeadersTest < ActionDispatch::IntegrationTest + include OauthAvailabilityTestHelper + + test "discovery documents" do + untenanted { get "/.well-known/oauth-authorization-server" } + assert_response :success + assert_uncached_without_referrer + + untenanted { get "/.well-known/oauth-protected-resource" } + assert_response :success + assert_uncached_without_referrer + end + + test "a dark server's answers" do + with_oauth_availability acceptance: false do + untenanted { get "/.well-known/oauth-authorization-server" } + assert_response :not_found + assert_uncached_without_referrer + + untenanted { post oauth_token_path, params: { grant_type: "authorization_code" }, as: :json } + assert_response :not_found + assert_uncached_without_referrer + end + end + + test "the sign-in redirect ahead of consent" do + untenanted { get new_oauth_authorization_path, params: authorization_params } + + assert_response :redirect + assert_match %r{/session/new}, response.location + assert_uncached_without_referrer + end + + test "the consent screen and its redirect to the client" do + sign_in_as :david + + untenanted { get new_oauth_authorization_path, params: authorization_params } + assert_response :success + assert_uncached_without_referrer + + untenanted { post oauth_authorization_path, params: authorization_params } + assert_response :redirect + assert_uncached_without_referrer + end + + test "token, registration and revocation answers" do + untenanted { post oauth_token_path, params: { grant_type: "password" }, as: :json } + assert_response :bad_request + assert_uncached_without_referrer + + untenanted { post oauth_clients_path, params: { redirect_uris: [ "http://127.0.0.1:9999/cb" ] }, as: :json } + assert_response :created + assert_uncached_without_referrer + + untenanted { post oauth_revocation_path, params: { token: "unknown" } } + assert_response :ok + assert_uncached_without_referrer + end + + private + def authorization_params + { + client_id: oauth_clients(:mcp_client).client_id, + redirect_uri: "http://127.0.0.1:8888/callback", + response_type: "code", + code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + code_challenge_method: "S256", + scope: "read", + state: "xyz" + } + end + + def assert_uncached_without_referrer + assert_equal "no-store", response.headers["Cache-Control"] + assert_equal "no-referrer", response.headers["Referrer-Policy"] + end +end From 2f3f4d32a19dc7cc46677cc850b999100ac42ad7 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 17 Oct 2026 01:10:03 -0700 Subject: [PATCH 20/22] Fail a bare Basic scheme as client authentication, not ignore it "Authorization: Basic" with no credentials didn't match the scheme pattern, so it counted as no attempt: a public client's exchange went ahead, and revocation deleted a presented personal access token. Now it fails as invalid_client with a Basic challenge, like any malformed Basic header. --- .../concerns/oauth/client_authentication.rb | 4 +++- .../oauth_client_secret_basic_test.rb | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/app/controllers/concerns/oauth/client_authentication.rb b/app/controllers/concerns/oauth/client_authentication.rb index 57701777c6..d43275ae5d 100644 --- a/app/controllers/concerns/oauth/client_authentication.rb +++ b/app/controllers/concerns/oauth/client_authentication.rb @@ -10,7 +10,9 @@ # registered token_endpoint_auth_method is advisory. Using both at once is # refused, since a client MUST NOT use more than one method per request (§2.3). module Oauth::ClientAuthentication - BASIC_SCHEME = /\ABasic /i + # The scheme alone, credentials or not: a bare "Basic" is a malformed attempt, + # not an absent one. + BASIC_SCHEME = /\ABasic(?: |\z)/i private # RFC 6749 §5.2 names more than one authentication method invalid_request. diff --git a/test/integration/oauth_client_secret_basic_test.rb b/test/integration/oauth_client_secret_basic_test.rb index 5b3a1d759c..9ffeac2ca6 100644 --- a/test/integration/oauth_client_secret_basic_test.rb +++ b/test/integration/oauth_client_secret_basic_test.rb @@ -98,6 +98,23 @@ class OauthClientSecretBasicTest < ActionDispatch::IntegrationTest assert_equal token.refresh_token, token.reload.refresh_token end + test "a bare Basic scheme is a failed attempt, not an absent one" do + client = oauth_clients(:mcp_client) + token = grant_for(client) + personal = identities(:david).access_tokens.create!(description: "Personal") + + [ "Basic", "Basic ", "basic" ].each do |header| + refresh token, params: { client_id: client.client_id }, headers: { "Authorization" => header } + assert_client_authentication_failed header + + untenanted { post oauth_revocation_path, params: { token: personal.token }, headers: { "Authorization" => header } } + assert_client_authentication_failed header + end + + assert Identity::AccessToken.exists?(personal.id) + assert_equal token.refresh_token, token.reload.refresh_token + end + test "Basic naming a public client fails: a public client holds no password" do client = oauth_clients(:mcp_client) token = grant_for(client) From 274c56688b1872aff9b09e10a645e03d54bdd826 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 17 Oct 2026 01:19:10 -0700 Subject: [PATCH 21/22] Read the grant's refresh token before the bare-Basic attempts, not after reloading --- test/integration/oauth_client_secret_basic_test.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/integration/oauth_client_secret_basic_test.rb b/test/integration/oauth_client_secret_basic_test.rb index 9ffeac2ca6..64d60f8ab8 100644 --- a/test/integration/oauth_client_secret_basic_test.rb +++ b/test/integration/oauth_client_secret_basic_test.rb @@ -101,6 +101,7 @@ class OauthClientSecretBasicTest < ActionDispatch::IntegrationTest test "a bare Basic scheme is a failed attempt, not an absent one" do client = oauth_clients(:mcp_client) token = grant_for(client) + refresh_token = token.refresh_token personal = identities(:david).access_tokens.create!(description: "Personal") [ "Basic", "Basic ", "basic" ].each do |header| @@ -112,7 +113,7 @@ class OauthClientSecretBasicTest < ActionDispatch::IntegrationTest end assert Identity::AccessToken.exists?(personal.id) - assert_equal token.refresh_token, token.reload.refresh_token + assert_equal refresh_token, token.reload.refresh_token end test "Basic naming a public client fails: a public client holds no password" do From 7427c08a508cd7e144392dcfe1f515d181694262 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 25 Oct 2026 22:26:56 -0500 Subject: [PATCH 22/22] Redact OAuth client statements from logged SQL, now that a client has a secret A confidential client's secret is stored as given, so every literal on oauth_clients is dropped from logs like the other credential tables'. --- lib/rails_ext/active_record_sql_redaction.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/rails_ext/active_record_sql_redaction.rb b/lib/rails_ext/active_record_sql_redaction.rb index 74d2d01be2..4a6a36b941 100644 --- a/lib/rails_ext/active_record_sql_redaction.rb +++ b/lib/rails_ext/active_record_sql_redaction.rb @@ -11,6 +11,7 @@ # identity_access_tokens.token API bearer token # identity_transfers.token signs its holder in as the identity # magic_links.code signs its holder in as the identity +# oauth_clients.client_secret authenticates a confidential client # webhooks.signing_secret, .url signs payloads; a chat webhook URL is itself a credential # # Filtering by table rather than column keeps INSERT ... VALUES, multi-row inserts and IN lists covered @@ -21,7 +22,7 @@ # test/lib/rails_ext/active_record_sql_redaction_test.rb until its table is listed here or the column is # declared not secret there. A credential in a column named otherwise has to be listed by hand. module ActiveRecordSqlRedaction - TABLES = %w[ account_join_codes identity_access_tokens identity_transfers magic_links webhooks ].freeze + TABLES = %w[ account_join_codes identity_access_tokens identity_transfers magic_links oauth_clients webhooks ].freeze # Names a table however the statement spells it: `magic_links` on MySQL, "magic_links" on SQLite, or bare # in hand-written SQL, in any case. Over-matching only filters more.