diff --git a/app/controllers/concerns/oauth/client_authentication.rb b/app/controllers/concerns/oauth/client_authentication.rb new file mode 100644 index 0000000000..d43275ae5d --- /dev/null +++ b/app/controllers/concerns/oauth/client_authentication.rb @@ -0,0 +1,134 @@ +# 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 + # 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. + # 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 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? && oauth_client_secret.blank? + 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) + 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 + + # 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 + 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. + 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/base_controller.rb b/app/controllers/oauth/base_controller.rb index 21e253ece4..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. @@ -32,6 +40,15 @@ 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 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 bbde1a477f..2642b39936 100644 --- a/app/controllers/oauth/clients_controller.rb +++ b/app/controllers/oauth/clients_controller.rb @@ -15,6 +15,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 @@ -54,11 +55,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?(Oauth::Client::AUTH_METHODS) + oauth_error "invalid_client_metadata", "token_endpoint_auth_method must be one of #{Oauth::Client::AUTH_METHODS.join(", ")}" 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) } @@ -95,12 +100,14 @@ def validated_scopes 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: "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..6a8104276d 100644 --- a/app/controllers/oauth/metadata_controller.rb +++ b/app/controllers/oauth/metadata_controller.rb @@ -10,11 +10,11 @@ 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 ], - token_endpoint_auth_methods_supported: %w[ none ], + 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/revocations_controller.rb b/app/controllers/oauth/revocations_controller.rb index 1d63849836..b41b486691 100644 --- a/app/controllers/oauth/revocations_controller.rb +++ b/app/controllers/oauth/revocations_controller.rb @@ -1,22 +1,54 @@ +# 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, 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 + allow_unauthenticated_access skip_forgery_protection - before_action :set_access_token + # 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 def create - @access_token&.destroy + if attempts_client_authentication? && !authenticated_client + client_authentication_failed + else + revocable_token&.destroy - head :ok # Don't behave as oracle, per RFC 7009 + 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_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/app/controllers/oauth/tokens_controller.rb b/app/controllers/oauth/tokens_controller.rb index 45978d500e..ce7601105e 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 @@ -7,18 +9,29 @@ class Oauth::TokensController < Oauth::BaseController before_action :require_issuance_enabled rate_limit to: 20, within: 1.minute, only: :create, with: :oauth_rate_limit_exceeded 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 before_action :set_auth_code before_action :set_client + end + + before_action :set_refreshable_access_token, unless: :authorization_code_grant? + 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_refreshable_access_token, unless: :authorization_code_grant? - before_action :validate_client_id before_action :set_refresh_scope, unless: :authorization_code_grant? def create @@ -55,7 +68,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 @@ -65,7 +79,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 ]) + (client_secret_basic? ? [] : %w[ client_id ]) end def string_param?(name) @@ -110,9 +124,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 params[:client_id] == (@client || @access_token.oauth_client).client_id - oauth_error "invalid_grant", "Grant was not issued to this client" + unless oauth_client_id == (@client || @access_token.oauth_client).client_id + oauth_error "invalid_grant", authorization_code_grant? ? "Invalid or expired authorization code" : "Invalid refresh token" end end @@ -137,6 +153,22 @@ def granted_scopes(permission) Oauth.canonical_scope(permission).split end + # 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 + reject_ambiguous_client_credentials + + unless performed? || !attempts_client_authentication? || authenticated_client&.confidential? + client_authentication_failed + 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..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,8 +16,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: AUTH_METHODS } validate :redirect_uris_are_valid + before_create :generate_client_secret, if: :confidential? + attribute :redirect_uris, default: -> { [] } attribute :scopes, default: -> { %w[ read ] } @@ -20,6 +28,15 @@ class Oauth::Client < ApplicationRecord scope :dynamically_registered, -> { where dynamically_registered: true } + def confidential? + token_endpoint_auth_method.in?(SECRET_AUTH_METHODS) + end + + def authenticate_secret(secret) + confidential? && client_secret.present? && secret.is_a?(String) && 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 +54,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/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. 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_availability_test.rb b/test/integration/oauth_availability_test.rb index 1fc7923f73..ce44af86ae 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" @@ -212,6 +213,30 @@ 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_client_authentication_failed basic: false + assert_equal token.refresh_token, token.reload.refresh_token + end + # Acceptance test "OAuth bearer tokens are refused while acceptance is off" do @@ -249,7 +274,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_client_secret_basic_test.rb b/test/integration/oauth_client_secret_basic_test.rb new file mode 100644 index 0000000000..64d60f8ab8 --- /dev/null +++ b/test/integration/oauth_client_secret_basic_test.rb @@ -0,0 +1,221 @@ +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 "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| + 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 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 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, 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 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 basic: false + 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 30e6663648..ac4502a117 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 @@ -764,6 +766,322 @@ 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", + client_id: client.client_id, + code: code, + redirect_uri: "https://connector.example.com/callback", + code_verifier: code_verifier + }, as: :json + end + + assert_client_authentication_failed basic: false + + # 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 + 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_client_authentication_failed basic: false + 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_client_authentication_failed basic: false + 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_id: client.client_id, + client_secret: "confidential_secret_789" + }, as: :json + end + end + + assert_response :success + 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_client_authentication_failed basic: false + 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_client_authentication_failed basic: false + 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_client_authentication_failed basic: false + + 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 + + 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_client_authentication_failed basic: false + 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_client_authentication_failed basic: false + 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" } => :bad_request, + { 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 } + + assert_response status + 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 "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) + + 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_client_authentication_failed basic: false + end + + # Refresh Grant test "refresh grant rotates access and refresh tokens" do @@ -1055,11 +1373,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 @@ -1071,7 +1389,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 @@ -1107,18 +1425,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) @@ -1139,9 +1445,10 @@ 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"] end test "protected resource metadata includes authorization server" do @@ -1399,6 +1706,56 @@ 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_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 + + 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") + assert_not body.key?("client_secret_expires_at") + 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: "JWT Client", + redirect_uris: [ "https://connector.example.com/callback" ], + token_endpoint_auth_method: "private_key_jwt" + }, 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/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 diff --git a/test/integration/oauth_revocation_test.rb b/test/integration/oauth_revocation_test.rb new file mode 100644 index 0000000000..34af3825a0 --- /dev/null +++ b/test/integration/oauth_revocation_test.rb @@ -0,0 +1,219 @@ +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, basic: false + 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 + + # 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. + + 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 + + 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, via_basic| + revoke token.token, params: params, headers: headers + + assert_client_authentication_failed [ params, headers ].inspect, basic: via_basic + 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)) + + [ 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 "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 }, + { 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 + + 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) + + 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 + + # 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" } + + assert_equal %w[ none client_secret_post client_secret_basic ], response.parsed_body["revocation_endpoint_auth_methods_supported"] + 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 + + def revoke(token, params: {}, headers: {}) + untenanted { post oauth_revocation_path, params: { token: token }.merge(params), headers: headers } + end +end diff --git a/test/models/oauth/client_test.rb b/test/models/oauth/client_test.rb index 1a6d4f1fbd..c0961e62eb 100644 --- a/test/models/oauth/client_test.rb +++ b/test/models/oauth/client_test.rb @@ -239,6 +239,52 @@ 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 "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: "JWT", + redirect_uris: %w[ http://127.0.0.1/cb ], + 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" + 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 client.authenticate_secret([ "confidential_secret_789" ]) + assert_not oauth_clients(:mcp_client).authenticate_secret("confidential_secret_789") + end + test "trusted scope" do trusted = Oauth::Client.trusted assert trusted.all?(&:trusted?) 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..fa89adb51f --- /dev/null +++ b/test/test_helpers/oauth_client_credentials_test_helper.rb @@ -0,0 +1,22 @@ +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 + + # 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 basic + assert_equal %(Basic realm="http://www.example.com/"), response.headers["WWW-Authenticate"], message + else + assert_nil response.headers["WWW-Authenticate"], message + end + end +end