From 0521fe11bb21d9a96b98a17383b0bb5b2616e594 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 5 Jan 2026 12:36:37 -0800 Subject: [PATCH 01/26] OAuth 2.1 support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OAuth tokens = Access Token that belongs to an OAuth client - One table (oauth_clients) + one column (oauth_client_id) - Stateless authorization codes via MessageEncryptor (60s TTL) - Implicit grants (token exists = grant, revoke = delete tokens) - Dynamic client registration for MCPs - Token lifetime: no expiry, explicit revocation only - Scope mapping: space-delimited OAuth scopes → permission enum - Security: PKCE required, loopback-only DCR, rate limiting - RFC compliance: 6749, 6750, 7636, 7591, 8252, 8414, 9728 Authorization flow with PKCE (S256 only) - Consent screen showing client name and requested scopes - Stateless authorization codes via MessageEncryptor Token endpoint - Authorization code exchange with PKCE verification - Issues Identity::AccessToken linked to OAuth client Revocation endpoint (RFC 7009) - Revoke access tokens by value - Always returns 200 per spec --- .../my/access_tokens_controller.rb | 2 +- .../oauth/authorizations_controller.rb | 108 +++++ app/controllers/oauth/base_controller.rb | 12 + .../oauth/revocations_controller.rb | 16 + app/controllers/oauth/tokens_controller.rb | 61 +++ app/models/identity/access_token.rb | 4 + app/models/oauth.rb | 7 + app/models/oauth/authorization_code.rb | 38 ++ app/models/oauth/client.rb | 74 +++ app/views/oauth/authorizations/error.html.erb | 15 + app/views/oauth/authorizations/new.html.erb | 40 ++ config/routes.rb | 6 + db/migrate/20251231163456_add_oauth.rb | 18 + db/schema.rb | 16 + db/schema_sqlite.rb | 14 + test/fixtures/oauth/clients.yml | 21 + test/integration/oauth_flow_test.rb | 437 ++++++++++++++++++ test/models/oauth/authorization_code_test.rb | 137 ++++++ test/models/oauth/client_test.rb | 137 ++++++ 19 files changed, 1162 insertions(+), 1 deletion(-) create mode 100644 app/controllers/oauth/authorizations_controller.rb create mode 100644 app/controllers/oauth/base_controller.rb create mode 100644 app/controllers/oauth/revocations_controller.rb create mode 100644 app/controllers/oauth/tokens_controller.rb create mode 100644 app/models/oauth.rb create mode 100644 app/models/oauth/authorization_code.rb create mode 100644 app/models/oauth/client.rb create mode 100644 app/views/oauth/authorizations/error.html.erb create mode 100644 app/views/oauth/authorizations/new.html.erb create mode 100644 db/migrate/20251231163456_add_oauth.rb create mode 100644 test/fixtures/oauth/clients.yml create mode 100644 test/integration/oauth_flow_test.rb create mode 100644 test/models/oauth/authorization_code_test.rb create mode 100644 test/models/oauth/client_test.rb diff --git a/app/controllers/my/access_tokens_controller.rb b/app/controllers/my/access_tokens_controller.rb index dc7e9826a1..1d52e8cd5e 100644 --- a/app/controllers/my/access_tokens_controller.rb +++ b/app/controllers/my/access_tokens_controller.rb @@ -45,7 +45,7 @@ def destroy private def my_access_tokens - Current.identity.access_tokens + Current.identity.access_tokens.personal end def access_token_params diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb new file mode 100644 index 0000000000..bb605e0acb --- /dev/null +++ b/app/controllers/oauth/authorizations_controller.rb @@ -0,0 +1,108 @@ +class Oauth::AuthorizationsController < Oauth::BaseController + before_action :save_oauth_return_url + before_action :require_authentication + + before_action :set_client + before_action :validate_redirect_uri + before_action :validate_response_type + before_action :validate_pkce + before_action :validate_scope + before_action :validate_state + + def new + @scope = params[:scope].presence || "read" + @redirect_uri = params[:redirect_uri] + @state = params[:state] + @code_challenge = params[:code_challenge] + end + + def create + if params[:error] == "access_denied" + redirect_to error_redirect_uri("access_denied", "User denied the request"), allow_other_host: true + else + code = Oauth::AuthorizationCode.generate \ + client_id: @client.client_id, + identity_id: Current.identity.id, + code_challenge: params[:code_challenge], + redirect_uri: params[:redirect_uri], + scope: params[:scope].presence || "read" + + redirect_to success_redirect_uri(code), allow_other_host: true + end + end + + private + def save_oauth_return_url + session[:return_to_after_authenticating] = request.url if request.get? && !authenticated? + end + + def set_client + @client = Oauth::Client.find_by(client_id: params[:client_id]) + oauth_error("invalid_request", "Unknown client") unless @client + end + + def validate_redirect_uri + unless performed? || @client.allows_redirect?(params[:redirect_uri]) + redirect_with_error "invalid_request", "Invalid redirect_uri" + end + end + + def validate_response_type + unless performed? || params[:response_type] == "code" + redirect_with_error "unsupported_response_type", "Only 'code' response_type is supported" + end + end + + def validate_pkce + unless performed? || params[:code_challenge].present? + redirect_with_error "invalid_request", "code_challenge is required" + end + + unless performed? || params[:code_challenge_method] == "S256" + redirect_with_error "invalid_request", "code_challenge_method must be S256" + end + end + + def validate_scope + unless performed? || @client.allows_scope?(params[:scope].presence || "read") + redirect_with_error "invalid_scope", "Requested scope is not allowed" + end + end + + def validate_state + unless performed? || params[:state].present? + redirect_with_error "invalid_request", "state is required" + end + end + + def redirect_with_error(error, description) + if params[:redirect_uri].present? && @client&.allows_redirect?(params[:redirect_uri]) + redirect_to error_redirect_uri(error, description), allow_other_host: true + else + @error = error + @error_description = description + render :error, status: :bad_request + end + end + + def success_redirect_uri(code) + build_redirect_uri params[:redirect_uri], + code: code, + state: params[:state].presence + end + + def error_redirect_uri(error, description) + build_redirect_uri params[:redirect_uri], + error: error, + error_description: description, + state: params[:state].presence + end + + def build_redirect_uri(base, **query_params) + uri = URI.parse(base) + query = URI.decode_www_form(uri.query || "") + query_params.compact.each { |k, v| query << [ k.to_s, v ] } + uri.query = URI.encode_www_form(query) + uri.to_s + end +end diff --git a/app/controllers/oauth/base_controller.rb b/app/controllers/oauth/base_controller.rb new file mode 100644 index 0000000000..658d1e2d48 --- /dev/null +++ b/app/controllers/oauth/base_controller.rb @@ -0,0 +1,12 @@ +class Oauth::BaseController < ApplicationController + disallow_account_scope + + private + def oauth_error(error, description = nil, status: :bad_request) + render json: { error: error, error_description: description }.compact, status: status + end + + def oauth_rate_limit_exceeded + oauth_error "slow_down", "Too many requests", status: :too_many_requests + end +end diff --git a/app/controllers/oauth/revocations_controller.rb b/app/controllers/oauth/revocations_controller.rb new file mode 100644 index 0000000000..6dca9777d3 --- /dev/null +++ b/app/controllers/oauth/revocations_controller.rb @@ -0,0 +1,16 @@ +class Oauth::RevocationsController < Oauth::BaseController + allow_unauthenticated_access + + before_action :set_access_token + + def create + @access_token&.destroy + + head :ok # Don't behave as oracle, per RFC 7009 + end + + private + def set_access_token + @access_token = Identity::AccessToken.find_by(token: params.require(:token)) + end +end diff --git a/app/controllers/oauth/tokens_controller.rb b/app/controllers/oauth/tokens_controller.rb new file mode 100644 index 0000000000..11cfefce0b --- /dev/null +++ b/app/controllers/oauth/tokens_controller.rb @@ -0,0 +1,61 @@ +class Oauth::TokensController < Oauth::BaseController + allow_unauthenticated_access + + rate_limit to: 20, within: 1.minute, only: :create, with: :oauth_rate_limit_exceeded + + before_action :validate_grant_type + before_action :set_auth_code + before_action :set_client + before_action :validate_pkce + before_action :validate_redirect_uri + before_action :set_identity + + def create + granted = @auth_code.scope.to_s.split + permission = granted.include?("write") ? "write" : "read" + access_token = @identity.access_tokens.create! oauth_client: @client, permission: permission + + render json: { + access_token: access_token.token, + token_type: "Bearer", + scope: granted.join(" ") + } + end + + private + def validate_grant_type + unless params[:grant_type] == "authorization_code" + oauth_error "unsupported_grant_type", "Only authorization_code grant is supported" + end + end + + def set_auth_code + unless @auth_code = Oauth::AuthorizationCode.parse(params[:code]) + oauth_error "invalid_grant", "Invalid or expired authorization code" + end + end + + def set_client + unless @client = Oauth::Client.find_by(client_id: @auth_code.client_id) + oauth_error "invalid_grant", "Unknown client" + end + end + + def validate_pkce + unless Oauth::AuthorizationCode.valid_pkce?(@auth_code, params[:code_verifier]) + oauth_error "invalid_grant", "PKCE verification failed" + end + end + + def validate_redirect_uri + unless @auth_code.redirect_uri == params[:redirect_uri] + oauth_error "invalid_grant", "redirect_uri mismatch" + end + end + + def set_identity + unless @identity = Identity.find_by(id: @auth_code.identity_id) + oauth_error "invalid_grant", "Identity not found" + end + end +end diff --git a/app/models/identity/access_token.rb b/app/models/identity/access_token.rb index abdf37eba8..6cd1fc3b24 100644 --- a/app/models/identity/access_token.rb +++ b/app/models/identity/access_token.rb @@ -1,5 +1,9 @@ class Identity::AccessToken < ApplicationRecord belongs_to :identity + belongs_to :oauth_client, class_name: "Oauth::Client", optional: true + + scope :personal, -> { where oauth_client_id: nil } + scope :oauth, -> { where.not oauth_client_id: nil } has_secure_token enum :permission, %w[ read write ].index_by(&:itself), default: :read diff --git a/app/models/oauth.rb b/app/models/oauth.rb new file mode 100644 index 0000000000..5770882e85 --- /dev/null +++ b/app/models/oauth.rb @@ -0,0 +1,7 @@ +module Oauth + LOOPBACK_HOSTS = %w[ 127.0.0.1 localhost ::1 [::1] ] + + def self.table_name_prefix + "oauth_" + end +end diff --git a/app/models/oauth/authorization_code.rb b/app/models/oauth/authorization_code.rb new file mode 100644 index 0000000000..32460b57f1 --- /dev/null +++ b/app/models/oauth/authorization_code.rb @@ -0,0 +1,38 @@ +module Oauth::AuthorizationCode + Details = ::Data.define(:client_id, :identity_id, :code_challenge, :redirect_uri, :scope) + + class << self + def generate(client_id:, identity_id:, code_challenge:, redirect_uri:, scope:) + payload = { client_id:, identity_id:, code_challenge:, redirect_uri:, scope: } + encryptor.encrypt_and_sign(payload, expires_in: 60.seconds) + end + + def parse(code) + if code.present? && data = encryptor.decrypt_and_verify(code) + Details.new \ + client_id: data["client_id"], + identity_id: data["identity_id"], + code_challenge: data["code_challenge"], + redirect_uri: data["redirect_uri"], + scope: data["scope"] + end + rescue ActiveSupport::MessageEncryptor::InvalidMessage, ActiveSupport::MessageVerifier::InvalidSignature + nil + end + + def valid_pkce?(code_data, code_verifier) + code_data && code_verifier.present? && + ActiveSupport::SecurityUtils.secure_compare(pkce_challenge(code_verifier), code_data.code_challenge) + end + + private + def encryptor + @encryptor ||= ActiveSupport::MessageEncryptor.new \ + Rails.application.key_generator.generate_key("oauth/authorization_codes", 32) + end + + def pkce_challenge(verifier) + Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false) + end + end +end diff --git a/app/models/oauth/client.rb b/app/models/oauth/client.rb new file mode 100644 index 0000000000..dd29156169 --- /dev/null +++ b/app/models/oauth/client.rb @@ -0,0 +1,74 @@ +class Oauth::Client < ApplicationRecord + has_many :access_tokens, class_name: "Identity::AccessToken" + + has_secure_token :client_id, length: 32 + + validates :name, presence: true + validates :client_id, uniqueness: true, allow_nil: true + validates :redirect_uris, presence: true + validate :redirect_uris_are_valid + + attribute :redirect_uris, default: -> { [] } + attribute :scopes, default: -> { %w[ read ] } + + scope :trusted, -> { where trusted: true } + scope :dynamically_registered, -> { where dynamically_registered: true } + + + def loopback? + redirect_uris.all? { |uri| loopback_uri?(uri) } + end + + def allows_redirect?(uri) + redirect_uris.include?(uri) || (loopback? && loopback_uri?(uri) && matching_loopback?(uri)) + end + + def allows_scope?(requested_scope) + requested = requested_scope.to_s.split + requested.present? && requested.all? { |s| scopes.include?(s) } + end + + private + def redirect_uris_are_valid + redirect_uris.each { |uri| validate_redirect_uri(uri) } + end + + def validate_redirect_uri(uri) + parsed = URI.parse(uri) + + if parsed.fragment.present? + errors.add :redirect_uris, "must not contain fragments" + end + + if dynamically_registered? && !valid_loopback_uri?(parsed) + errors.add :redirect_uris, "must be a local loopback URI for dynamically registered clients" + end + rescue URI::InvalidURIError + errors.add :redirect_uris, "includes an invalid URI" + end + + def loopback_uri?(uri) + Oauth::LOOPBACK_HOSTS.include?(URI.parse(uri).host) + rescue URI::InvalidURIError + false + end + + def valid_loopback_uri?(parsed) + parsed.scheme == "http" && parsed.host.in?(Oauth::LOOPBACK_HOSTS) + end + + def matching_loopback?(uri) + parsed = URI.parse(uri) + + redirect_uris.any? do |redirect_uri| + redirect = URI.parse(redirect_uri) + + redirect.scheme == parsed.scheme && + redirect.host.in?(Oauth::LOOPBACK_HOSTS) && + parsed.host.in?(Oauth::LOOPBACK_HOSTS) && + redirect.path == parsed.path + end + rescue URI::InvalidURIError + false + end +end diff --git a/app/views/oauth/authorizations/error.html.erb b/app/views/oauth/authorizations/error.html.erb new file mode 100644 index 0000000000..3493d9a7c2 --- /dev/null +++ b/app/views/oauth/authorizations/error.html.erb @@ -0,0 +1,15 @@ +
+
+

Authorization Error

+ +

+ <%= @error_description %> +

+ +

+ Error code: <%= @error %> +

+ + <%= link_to "Go back", request.referer || root_path, class: "btn btn--outline center txt-medium" %> +
+
diff --git a/app/views/oauth/authorizations/new.html.erb b/app/views/oauth/authorizations/new.html.erb new file mode 100644 index 0000000000..c1ce809dc8 --- /dev/null +++ b/app/views/oauth/authorizations/new.html.erb @@ -0,0 +1,40 @@ +<% @page_title = "Authorize #{@client.name}" %> + +<% content_for :header do %> +

<%= @page_title %>

+<% end %> + +
+ <%= form_with url: oauth_authorization_path, method: :post, data: { turbo: false }, html: { class: "flex flex-column gap" } do |f| %> + <%= f.hidden_field :client_id, value: @client.client_id %> + <%= f.hidden_field :redirect_uri, value: @redirect_uri %> + <%= f.hidden_field :state, value: @state %> + <%= f.hidden_field :code_challenge, value: @code_challenge %> + <%= f.hidden_field :code_challenge_method, value: "S256" %> + <%= f.hidden_field :response_type, value: "code" %> + +
+ Application +

<%= @client.name %>

+ <% if @client.dynamically_registered? %> +

Registered by a local tool. Only authorize if you trust it.

+ <% end %> +
+ +
+ <%= f.label :scope, "Permission" %> + <% scope_options = @client.scopes.include?("write") ? { "Read" => "read", "Read + Write" => "write" } : { "Read" => "read" } %> + <%= f.select :scope, options_for_select(scope_options, @scope), {}, class: "input input--select" %> +
+ +
+ <%= f.button type: :submit, class: "btn btn--link txt-medium" do %> + Authorize + <% end %> + + <%= f.button type: :submit, name: :error, value: "access_denied", class: "btn btn--outline txt-medium" do %> + Deny + <% end %> +
+ <% end %> +
diff --git a/config/routes.rb b/config/routes.rb index fa94828ed1..f457593922 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,12 @@ Rails.application.routes.draw do root "events#index" + namespace :oauth do + resource :authorization, only: %i[ new create ] + resource :token, only: :create + resource :revocation, only: :create + end + namespace :account do resource :cancellation, only: [ :create ] resource :entropy diff --git a/db/migrate/20251231163456_add_oauth.rb b/db/migrate/20251231163456_add_oauth.rb new file mode 100644 index 0000000000..74e6c36a26 --- /dev/null +++ b/db/migrate/20251231163456_add_oauth.rb @@ -0,0 +1,18 @@ +class AddOauth < ActiveRecord::Migration[8.2] + def change + create_table :oauth_clients, id: :uuid do |t| + t.string :client_id, null: false + t.string :name, null: false + t.json :redirect_uris + t.json :scopes + t.boolean :trusted, default: false + t.boolean :dynamically_registered, default: false + + t.timestamps + + t.index :client_id, unique: true + end + + add_reference :identity_access_tokens, :oauth_client, type: :uuid, foreign_key: false + end +end diff --git a/db/schema.rb b/db/schema.rb index c706383b4b..387c954a90 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -355,10 +355,12 @@ t.datetime "created_at", null: false t.text "description" t.uuid "identity_id", null: false + t.uuid "oauth_client_id" t.string "permission" t.string "token" t.datetime "updated_at", null: false t.index ["identity_id"], name: "index_access_token_on_identity_id" + t.index ["oauth_client_id"], name: "index_identity_access_tokens_on_oauth_client_id" end create_table "identity_transfers", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -429,6 +431,18 @@ t.index ["user_id"], name: "index_notifications_on_user_id" end + create_table "oauth_clients", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "client_id", null: false + 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.boolean "trusted", default: false + t.datetime "updated_at", null: false + t.index ["client_id"], name: "index_oauth_clients_on_client_id", unique: true + end + create_table "pins", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.uuid "card_id", null: false @@ -866,4 +880,6 @@ t.index ["account_id"], name: "index_webhooks_on_account_id" t.index ["board_id", "subscribed_actions"], name: "index_webhooks_on_board_id_and_subscribed_actions", length: { subscribed_actions: 255 } end + + add_foreign_key "identity_access_tokens", "oauth_clients" end diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index 312261878f..06258b424b 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -355,10 +355,12 @@ t.datetime "created_at", null: false t.text "description", limit: 65535 t.uuid "identity_id", null: false + t.uuid "oauth_client_id" t.string "permission", limit: 255 t.string "token", limit: 255 t.datetime "updated_at", null: false t.index ["identity_id"], name: "index_access_token_on_identity_id" + t.index ["oauth_client_id"], name: "index_identity_access_tokens_on_oauth_client_id" end create_table "identity_transfers", id: :uuid, force: :cascade do |t| @@ -429,6 +431,18 @@ t.index ["user_id"], name: "index_notifications_on_user_id" end + create_table "oauth_clients", id: :uuid, force: :cascade do |t| + t.string "client_id", limit: 255, null: false + 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.boolean "trusted", default: false + t.datetime "updated_at", null: false + t.index ["client_id"], name: "index_oauth_clients_on_client_id", unique: true + end + create_table "pins", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.uuid "card_id", null: false diff --git a/test/fixtures/oauth/clients.yml b/test/fixtures/oauth/clients.yml new file mode 100644 index 0000000000..86b6de7264 --- /dev/null +++ b/test/fixtures/oauth/clients.yml @@ -0,0 +1,21 @@ +mcp_client: + client_id: mcp_test_client_123 + name: Test MCP Client + redirect_uris: + - http://127.0.0.1:8888/callback + scopes: + - read + - write + dynamically_registered: true + trusted: false + +trusted_client: + client_id: trusted_client_456 + name: Trusted First-Party App + redirect_uris: + - https://app.example.com/oauth/callback + scopes: + - read + - write + dynamically_registered: false + trusted: true diff --git a/test/integration/oauth_flow_test.rb b/test/integration/oauth_flow_test.rb new file mode 100644 index 0000000000..b52d354163 --- /dev/null +++ b/test/integration/oauth_flow_test.rb @@ -0,0 +1,437 @@ +require "test_helper" + +class OauthFlowTest < ActionDispatch::IntegrationTest + # Authorization Endpoint + + test "authorization requires authentication" do + client = oauth_clients(:mcp_client) + + untenanted do + get new_oauth_authorization_path, params: { + client_id: client.client_id, + redirect_uri: "http://127.0.0.1:8888/callback", + response_type: "code", + code_challenge: "test_challenge", + code_challenge_method: "S256" + } + end + + assert_response :redirect + assert_match %r{/session/new}, response.location + end + + test "authorization shows consent screen" do + sign_in_as :david + client = oauth_clients(:mcp_client) + + untenanted do + get new_oauth_authorization_path, params: { + client_id: 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: "xyz123" + } + end + + assert_response :success + assert_select "form[action$=?]", "/oauth/authorization" + assert_match client.name, response.body + end + + test "authorization rejects invalid client_id" do + sign_in_as :david + + untenanted do + get new_oauth_authorization_path, params: { + client_id: "nonexistent", + redirect_uri: "http://127.0.0.1/cb", + response_type: "code", + code_challenge: "test", + code_challenge_method: "S256" + } + end + + assert_response :bad_request + assert_equal "invalid_request", response.parsed_body["error"] + end + + test "authorization rejects mismatched redirect_uri" do + sign_in_as :david + client = oauth_clients(:mcp_client) + + untenanted do + get new_oauth_authorization_path, params: { + client_id: client.client_id, + redirect_uri: "http://evil.com/steal", + response_type: "code", + code_challenge: "test", + code_challenge_method: "S256", + state: "abc" + } + end + + # Can't redirect to untrusted URI, so render HTML error page + assert_response :bad_request + assert_select "code", text: "invalid_request" + end + + test "authorization requires PKCE" do + sign_in_as :david + client = oauth_clients(:mcp_client) + + untenanted do + get new_oauth_authorization_path, params: { + client_id: client.client_id, + redirect_uri: "http://127.0.0.1:8888/callback", + response_type: "code", + state: "abc" + } + end + + # Per RFC 6749, redirect to client with error in query params + assert_response :redirect + redirect_params = CGI.parse(URI.parse(response.location).query) + assert_equal "invalid_request", redirect_params["error"].first + assert_match "code_challenge", redirect_params["error_description"].first + end + + test "authorization consent issues code" do + sign_in_as :david + client = oauth_clients(:mcp_client) + + untenanted do + post oauth_authorization_path, params: { + client_id: 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: "xyz123" + } + end + + assert_response :redirect + redirect_uri = URI.parse(response.location) + + assert_equal "127.0.0.1", redirect_uri.host + assert_equal "/callback", redirect_uri.path + + params = CGI.parse(redirect_uri.query) + assert_not_nil params["code"]&.first + assert_equal "xyz123", params["state"]&.first + end + + + # Token Endpoint + + test "token exchange with valid code and PKCE" do + code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + + client = oauth_clients(:mcp_client) + identity = identities(:david) + + code = Oauth::AuthorizationCode.generate \ + client_id: client.client_id, + identity_id: identity.id, + code_challenge: code_challenge, + redirect_uri: "http://127.0.0.1:8888/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: "http://127.0.0.1:8888/callback", + code_verifier: code_verifier + }, as: :json + end + end + + assert_response :success + body = response.parsed_body + + assert_not_nil body["access_token"] + assert_equal "Bearer", body["token_type"] + assert_equal "read", body["scope"] + + token = Identity::AccessToken.find_by(token: body["access_token"]) + assert_equal client, token.oauth_client + assert_equal identity, token.identity + assert_equal "read", token.permission + end + + test "token exchange rejects invalid code" do + untenanted do + post oauth_token_path, params: { + grant_type: "authorization_code", + code: "invalid_code", + redirect_uri: "http://127.0.0.1/cb", + code_verifier: "verifier" + }, as: :json + end + + assert_response :bad_request + assert_equal "invalid_grant", response.parsed_body["error"] + end + + test "token exchange rejects wrong PKCE verifier" do + code_verifier = "correct_verifier_here" + code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + + client = oauth_clients(:mcp_client) + + code = Oauth::AuthorizationCode.generate \ + client_id: client.client_id, + identity_id: identities(:david).id, + code_challenge: code_challenge, + redirect_uri: "http://127.0.0.1:8888/callback", + scope: "read" + + untenanted do + post oauth_token_path, params: { + grant_type: "authorization_code", + code: code, + redirect_uri: "http://127.0.0.1:8888/callback", + code_verifier: "wrong_verifier" + }, as: :json + end + + assert_response :bad_request + assert_equal "invalid_grant", response.parsed_body["error"] + end + + test "token exchange rejects mismatched redirect_uri" do + code_verifier = "verifier" + code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + + client = oauth_clients(:mcp_client) + + code = Oauth::AuthorizationCode.generate \ + client_id: client.client_id, + identity_id: identities(:david).id, + code_challenge: code_challenge, + redirect_uri: "http://127.0.0.1:8888/callback", + scope: "read" + + untenanted do + post oauth_token_path, params: { + grant_type: "authorization_code", + code: code, + redirect_uri: "http://127.0.0.1:9999/different", + code_verifier: code_verifier + }, as: :json + end + + assert_response :bad_request + assert_equal "invalid_grant", response.parsed_body["error"] + end + + test "token exchange rejects expired code" do + code_verifier = "verifier" + code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + + client = oauth_clients(:mcp_client) + + code = Oauth::AuthorizationCode.generate \ + client_id: client.client_id, + identity_id: identities(:david).id, + code_challenge: code_challenge, + redirect_uri: "http://127.0.0.1:8888/callback", + scope: "read" + + travel 65.seconds do + untenanted do + post oauth_token_path, params: { + grant_type: "authorization_code", + code: code, + redirect_uri: "http://127.0.0.1:8888/callback", + code_verifier: code_verifier + }, as: :json + end + end + + assert_response :bad_request + assert_equal "invalid_grant", response.parsed_body["error"] + end + + test "token exchange rejects unsupported grant type" do + untenanted do + post oauth_token_path, params: { grant_type: "client_credentials" }, as: :json + end + + assert_response :bad_request + assert_equal "unsupported_grant_type", response.parsed_body["error"] + end + + + # Token Revocation (RFC 7009) + + test "revocation deletes access token" do + token = identity_access_tokens(:davids_api_token) + + assert_difference "Identity::AccessToken.count", -1 do + untenanted do + post oauth_revocation_path, params: { token: token.token }, as: :json + end + end + + assert_response :success + end + + test "revocation returns 200 for nonexistent token" do + untenanted do + post oauth_revocation_path, params: { token: "nonexistent_token" }, as: :json + end + assert_response :success + end + + test "revocation returns 400 for blank token" do + untenanted do + post oauth_revocation_path, params: { token: "" }, as: :json + end + assert_response :bad_request + end + + + # Discovery Metadata (RFC 8414) + + test "authorization server metadata includes required fields" do + untenanted do + get "/.well-known/oauth-authorization-server" + end + + assert_response :success + body = response.parsed_body + + assert_equal "http://www.example.com/", body["issuer"] + assert_match %r{/oauth/authorization/new$}, body["authorization_endpoint"] + assert_match %r{/oauth/token$}, body["token_endpoint"] + assert_match %r{/oauth/clients$}, body["registration_endpoint"] + assert_includes body["response_types_supported"], "code" + assert_includes body["code_challenge_methods_supported"], "S256" + end + + test "protected resource metadata includes authorization server" do + untenanted do + get "/.well-known/oauth-protected-resource" + end + + assert_response :success + body = response.parsed_body + + assert_equal "http://www.example.com/", body["resource"] + assert_includes body["authorization_servers"], "http://www.example.com/" + end + + + # Dynamic Client Registration (RFC 7591) + + test "DCR creates client with loopback redirect" do + assert_difference "Oauth::Client.count", 1 do + untenanted do + post oauth_clients_path, params: { + client_name: "Test MCP Client", + redirect_uris: [ "http://127.0.0.1:8888/callback" ] + }, as: :json + end + end + + assert_response :created + body = response.parsed_body + + assert_not_nil body["client_id"] + assert_equal "Test MCP Client", body["client_name"] + assert_equal [ "http://127.0.0.1:8888/callback" ], body["redirect_uris"] + end + + test "DCR rejects non-loopback redirect" do + assert_no_difference "Oauth::Client.count" do + untenanted do + post oauth_clients_path, params: { + client_name: "Evil Client", + redirect_uris: [ "https://evil.com/steal" ] + }, as: :json + end + end + + assert_response :bad_request + assert_equal "invalid_redirect_uri", response.parsed_body["error"] + end + + test "DCR requires redirect_uris" do + untenanted do + post oauth_clients_path, params: { client_name: "No Redirect" }, as: :json + end + + assert_response :bad_request + assert_equal "invalid_client_metadata", response.parsed_body["error"] + end + + + # Full OAuth Flow + + test "complete authorization code flow" do + sign_in_as :david + client = oauth_clients(:mcp_client) + code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + + # Step 1: Get consent screen + untenanted do + get new_oauth_authorization_path, params: { + client_id: client.client_id, + redirect_uri: "http://127.0.0.1:8888/callback", + response_type: "code", + code_challenge: code_challenge, + code_challenge_method: "S256", + scope: "read", + state: "test_state" + } + end + assert_response :success + + # Step 2: Grant consent + untenanted do + post oauth_authorization_path, params: { + client_id: client.client_id, + redirect_uri: "http://127.0.0.1:8888/callback", + response_type: "code", + code_challenge: code_challenge, + code_challenge_method: "S256", + scope: "read", + state: "test_state" + } + end + assert_response :redirect + + # Extract code from redirect + redirect_uri = URI.parse(response.location) + params = CGI.parse(redirect_uri.query) + code = params["code"].first + assert_not_nil code + assert_equal "test_state", params["state"].first + + # Step 3: Exchange code for token + assert_difference "Identity::AccessToken.count", 1 do + untenanted do + post oauth_token_path, params: { + grant_type: "authorization_code", + code: code, + redirect_uri: "http://127.0.0.1:8888/callback", + code_verifier: code_verifier + }, as: :json + end + end + + assert_response :success + body = response.parsed_body + assert_not_nil body["access_token"] + assert_equal "Bearer", body["token_type"] + end +end diff --git a/test/models/oauth/authorization_code_test.rb b/test/models/oauth/authorization_code_test.rb new file mode 100644 index 0000000000..67610bcee2 --- /dev/null +++ b/test/models/oauth/authorization_code_test.rb @@ -0,0 +1,137 @@ +require "test_helper" + +class Oauth::AuthorizationCodeTest < ActiveSupport::TestCase + test "generate creates encrypted code" do + code = Oauth::AuthorizationCode.generate \ + client_id: "test_client", + identity_id: 123, + code_challenge: "abc123", + redirect_uri: "http://127.0.0.1:8888/callback", + scope: "read" + + assert_kind_of String, code + assert code.length > 50, "Encrypted code should be reasonably long" + end + + test "parse decrypts valid code" do + code = Oauth::AuthorizationCode.generate \ + client_id: "test_client", + identity_id: 456, + code_challenge: "challenge_hash", + redirect_uri: "http://127.0.0.1:8888/callback", + scope: "read write" + + parsed = Oauth::AuthorizationCode.parse(code) + + assert_not_nil parsed + assert_equal "test_client", parsed.client_id + assert_equal 456, parsed.identity_id + assert_equal "challenge_hash", parsed.code_challenge + assert_equal "http://127.0.0.1:8888/callback", parsed.redirect_uri + assert_equal "read write", parsed.scope + end + + test "parse returns nil for blank code" do + assert_nil Oauth::AuthorizationCode.parse("") + assert_nil Oauth::AuthorizationCode.parse(nil) + end + + test "parse returns nil for invalid code" do + assert_nil Oauth::AuthorizationCode.parse("garbage_data_here") + end + + test "parse returns nil for tampered code" do + code = Oauth::AuthorizationCode.generate \ + client_id: "test_client", + identity_id: 123, + code_challenge: "abc", + redirect_uri: "http://127.0.0.1/cb", + scope: "read" + + tampered = code[0...-10] + "XXXXXXXXXX" + assert_nil Oauth::AuthorizationCode.parse(tampered) + end + + test "parse returns nil for expired code" do + code = Oauth::AuthorizationCode.generate \ + client_id: "test_client", + identity_id: 123, + code_challenge: "abc", + redirect_uri: "http://127.0.0.1/cb", + scope: "read" + + travel 65.seconds do + assert_nil Oauth::AuthorizationCode.parse(code) + end + end + + test "code is valid within 60 second window" do + code = Oauth::AuthorizationCode.generate \ + client_id: "test_client", + identity_id: 123, + code_challenge: "abc", + redirect_uri: "http://127.0.0.1/cb", + scope: "read" + + travel 55.seconds do + parsed = Oauth::AuthorizationCode.parse(code) + assert_not_nil parsed + assert_equal "test_client", parsed.client_id + end + end + + test "valid_pkce? returns true for correct S256 verifier" do + code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + + details = Oauth::AuthorizationCode::Details.new \ + client_id: "test", + identity_id: 1, + code_challenge: code_challenge, + redirect_uri: "http://127.0.0.1/cb", + scope: "read" + + assert Oauth::AuthorizationCode.valid_pkce?(details, code_verifier) + end + + test "valid_pkce? returns false for wrong verifier" do + code_verifier = "correct_verifier" + code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + + details = Oauth::AuthorizationCode::Details.new \ + client_id: "test", + identity_id: 1, + code_challenge: code_challenge, + redirect_uri: "http://127.0.0.1/cb", + scope: "read" + + assert_not Oauth::AuthorizationCode.valid_pkce?(details, "wrong_verifier") + end + + test "valid_pkce? returns false for nil code details" do + assert_not Oauth::AuthorizationCode.valid_pkce?(nil, "verifier") + end + + test "valid_pkce? returns false for blank verifier" do + details = Oauth::AuthorizationCode::Details.new \ + client_id: "test", + identity_id: 1, + code_challenge: "challenge", + redirect_uri: "http://127.0.0.1/cb", + scope: "read" + + assert_not Oauth::AuthorizationCode.valid_pkce?(details, "") + assert_not Oauth::AuthorizationCode.valid_pkce?(details, nil) + end + + test "auth code details are immutable" do + details = Oauth::AuthorizationCode::Details.new \ + client_id: "test", + identity_id: 1, + code_challenge: "challenge", + redirect_uri: "http://127.0.0.1/cb", + scope: "read" + + assert_raises(FrozenError) { details.instance_variable_set(:@client_id, "hacked") } + end +end diff --git a/test/models/oauth/client_test.rb b/test/models/oauth/client_test.rb new file mode 100644 index 0000000000..55108b564e --- /dev/null +++ b/test/models/oauth/client_test.rb @@ -0,0 +1,137 @@ +require "test_helper" + +class Oauth::ClientTest < ActiveSupport::TestCase + test "generates client_id on create" do + client = Oauth::Client.create!(name: "Test", redirect_uris: %w[ http://127.0.0.1:8888/callback ]) + assert_equal 32, client.client_id.length + assert_match(/\A[a-zA-Z0-9]+\z/, client.client_id) + end + + test "client_id must be unique" do + existing = oauth_clients(:mcp_client) + client = Oauth::Client.new(name: "Dupe", client_id: existing.client_id, redirect_uris: %w[ http://127.0.0.1/cb ]) + assert_not client.valid? + assert_includes client.errors[:client_id], "has already been taken" + end + + test "name is required" do + client = Oauth::Client.new(redirect_uris: %w[ http://127.0.0.1/cb ]) + assert_not client.valid? + assert_includes client.errors[:name], "can't be blank" + end + + test "redirect_uris required" do + client = Oauth::Client.new(name: "Test") + assert_not client.valid? + assert_includes client.errors[:redirect_uris], "can't be blank" + end + + test "dynamically registered clients must use http loopback URIs" do + client = Oauth::Client.new( + name: "External", + redirect_uris: %w[ https://evil.com/callback ], + dynamically_registered: true + ) + assert_not client.valid? + assert_includes client.errors[:redirect_uris], "must be a local loopback URI for dynamically registered clients" + end + + test "dynamically registered clients reject https loopback" do + client = Oauth::Client.new( + name: "HTTPS Loopback", + redirect_uris: %w[ https://127.0.0.1:8888/callback ], + dynamically_registered: true + ) + assert_not client.valid? + assert_includes client.errors[:redirect_uris], "must be a local loopback URI for dynamically registered clients" + end + + test "redirect URIs must not contain fragments" do + client = Oauth::Client.new( + name: "Fragment", + redirect_uris: %w[ http://127.0.0.1:8888/callback#section ], + dynamically_registered: true + ) + assert_not client.valid? + assert_includes client.errors[:redirect_uris], "must not contain fragments" + end + + test "dynamically registered clients can use 127.0.0.1" do + client = Oauth::Client.new( + name: "Loopback", + redirect_uris: %w[ http://127.0.0.1:9999/callback ], + dynamically_registered: true + ) + assert client.valid? + end + + test "dynamically registered clients can use localhost" do + client = Oauth::Client.new( + name: "Localhost", + redirect_uris: %w[ http://localhost:9999/callback ], + dynamically_registered: true + ) + assert client.valid? + end + + test "dynamically registered clients can use IPv6 loopback" do + client = Oauth::Client.new( + name: "IPv6", + redirect_uris: %w[ http://[::1]:9999/callback ], + dynamically_registered: true + ) + assert client.valid? + end + + test "loopback? returns true for loopback-only clients" do + client = Oauth::Client.new(redirect_uris: %w[ http://127.0.0.1:8888/cb http://localhost:9999/cb ]) + assert client.loopback? + end + + test "loopback? returns false for non-loopback clients" do + client = Oauth::Client.new(redirect_uris: %w[ https://example.com/cb ]) + assert_not client.loopback? + end + + test "allows_redirect? matches exact URI" do + client = Oauth::Client.new(redirect_uris: %w[ http://127.0.0.1:8888/callback ]) + assert client.allows_redirect?("http://127.0.0.1:8888/callback") + assert_not client.allows_redirect?("http://127.0.0.1:8888/other") + end + + test "allows_redirect? allows different ports for loopback clients" do + client = Oauth::Client.new(redirect_uris: %w[ http://127.0.0.1:8888/callback ]) + assert client.allows_redirect?("http://127.0.0.1:9999/callback") + assert client.allows_redirect?("http://localhost:7777/callback") + end + + test "allows_redirect? requires matching path for loopback flexibility" do + client = Oauth::Client.new(redirect_uris: %w[ http://127.0.0.1:8888/callback ]) + assert_not client.allows_redirect?("http://127.0.0.1:9999/other") + end + + test "allows_scope? checks client scopes" do + client = Oauth::Client.new(scopes: %w[ read write ]) + assert client.allows_scope?("read") + assert client.allows_scope?("write") + assert client.allows_scope?("read write") + assert_not client.allows_scope?("admin") + assert_not client.allows_scope?("read admin") + assert_not client.allows_scope?("") + end + + test "default scopes are set" do + client = Oauth::Client.new(name: "Test", redirect_uris: %w[ http://127.0.0.1/cb ]) + assert_equal %w[ read ], client.scopes + end + + test "trusted scope" do + trusted = Oauth::Client.trusted + assert trusted.all?(&:trusted?) + end + + test "dynamically_registered scope" do + dcr_clients = Oauth::Client.dynamically_registered + assert dcr_clients.all?(&:dynamically_registered?) + end +end From 7b43efc9cdec6dcb5817b1f436a8bbb344ace5d8 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 5 Jan 2026 12:42:19 -0800 Subject: [PATCH 02/26] OAuth discovery and dynamic client registration Discovery endpoints (RFC 8414): - /.well-known/oauth-authorization-server - /.well-known/oauth-protected-resource Dynamic Client Registration (RFC 7591): - POST /oauth/clients for MCP clients - Loopback redirects only (127.0.0.1, localhost, [::1]) - Rate limited to 10 requests/minute --- app/controllers/oauth/clients_controller.rb | 75 +++++++++++++++++++ app/controllers/oauth/metadata_controller.rb | 18 +++++ .../protected_resource_metadata_controller.rb | 12 +++ config/routes.rb | 4 + 4 files changed, 109 insertions(+) create mode 100644 app/controllers/oauth/clients_controller.rb create mode 100644 app/controllers/oauth/metadata_controller.rb create mode 100644 app/controllers/oauth/protected_resource_metadata_controller.rb diff --git a/app/controllers/oauth/clients_controller.rb b/app/controllers/oauth/clients_controller.rb new file mode 100644 index 0000000000..df0c13f1a6 --- /dev/null +++ b/app/controllers/oauth/clients_controller.rb @@ -0,0 +1,75 @@ +class Oauth::ClientsController < Oauth::BaseController + allow_unauthenticated_access + + rate_limit to: 10, within: 1.minute, only: :create, with: :oauth_rate_limit_exceeded + + before_action :validate_redirect_uris + before_action :validate_loopback_uris + before_action :validate_auth_method + + def create + client = Oauth::Client.create! \ + name: params[:client_name] || "MCP Client", + redirect_uris: Array(params[:redirect_uris]), + scopes: validated_scopes, + dynamically_registered: true + + render json: dynamic_client_registration_response(client), status: :created + rescue ActiveRecord::RecordInvalid => e + oauth_error "invalid_client_metadata", e.message + end + + private + def validate_redirect_uris + unless performed? || params[:redirect_uris].present? + oauth_error "invalid_client_metadata", "redirect_uris is required" + end + end + + def validate_loopback_uris + unless performed? || all_loopback_uris?(params[:redirect_uris]) + oauth_error "invalid_redirect_uri", "Only loopback redirect URIs are allowed for dynamic registration" + end + 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" + end + end + + def all_loopback_uris?(uris) + uris.is_a?(Array) && + uris.all? { |uri| uri.is_a?(String) && valid_loopback_uri?(uri) } + end + + def valid_loopback_uri?(uri) + parsed = URI.parse(uri) + parsed.scheme == "http" && + Oauth::LOOPBACK_HOSTS.include?(parsed.host) && + parsed.fragment.nil? + rescue URI::InvalidURIError + false + end + + def validated_scopes + requested = case params[:scope] + when String then params[:scope].split + when Array then params[:scope].select { |s| s.is_a?(String) } + else [] + end + requested.select { |s| s.presence_in %w[ read write ] }.presence || %w[ read ] + end + + def dynamic_client_registration_response(client) + { + client_id: client.client_id, + client_name: client.name, + redirect_uris: client.redirect_uris, + token_endpoint_auth_method: "none", + grant_types: %w[ authorization_code ], + response_types: %w[ code ], + scope: client.scopes.join(" ") + } + end +end diff --git a/app/controllers/oauth/metadata_controller.rb b/app/controllers/oauth/metadata_controller.rb new file mode 100644 index 0000000000..9bc84ea15e --- /dev/null +++ b/app/controllers/oauth/metadata_controller.rb @@ -0,0 +1,18 @@ +class Oauth::MetadataController < Oauth::BaseController + allow_unauthenticated_access + + def show + render json: { + issuer: root_url(script_name: nil), + authorization_endpoint: new_oauth_authorization_url, + token_endpoint: oauth_token_url, + registration_endpoint: oauth_clients_url, + revocation_endpoint: oauth_revocation_url, + response_types_supported: %w[ code ], + grant_types_supported: %w[ authorization_code ], + token_endpoint_auth_methods_supported: %w[ none ], + code_challenge_methods_supported: %w[ S256 ], + scopes_supported: %w[ read write ] + } + end +end diff --git a/app/controllers/oauth/protected_resource_metadata_controller.rb b/app/controllers/oauth/protected_resource_metadata_controller.rb new file mode 100644 index 0000000000..d7f6d4895d --- /dev/null +++ b/app/controllers/oauth/protected_resource_metadata_controller.rb @@ -0,0 +1,12 @@ +class Oauth::ProtectedResourceMetadataController < Oauth::BaseController + allow_unauthenticated_access + + def show + render json: { + resource: root_url(script_name: nil), + authorization_servers: [ root_url(script_name: nil) ], + bearer_methods_supported: %w[ header ], + scopes_supported: %w[ read write ] + } + end +end diff --git a/config/routes.rb b/config/routes.rb index f457593922..1cd885e215 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,10 +1,14 @@ Rails.application.routes.draw do root "events#index" + get "/.well-known/oauth-authorization-server", to: "oauth/metadata#show" + get "/.well-known/oauth-protected-resource", to: "oauth/protected_resource_metadata#show" + namespace :oauth do resource :authorization, only: %i[ new create ] resource :token, only: :create resource :revocation, only: :create + resources :clients, only: :create end namespace :account do From 13ae0cb76210454035d9e715c57be57c57d29551 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 5 Jan 2026 12:42:55 -0800 Subject: [PATCH 03/26] Connected Apps UI for OAuth grant management View and revoke OAuth client access at /my/connected_apps. Scoped through identity's OAuth tokens for proper authorization. Links from access tokens index for discoverability. --- .../my/connected_apps_controller.rb | 28 +++++++++ .../my/connected_apps/_connected_app.html.erb | 13 ++++ app/views/my/connected_apps/index.html.erb | 32 ++++++++++ app/views/users/_access_tokens.html.erb | 2 +- app/views/users/_connected_apps.html.erb | 4 ++ app/views/users/show.html.erb | 1 + config/routes.rb | 1 + .../my/connected_apps_controller_test.rb | 61 +++++++++++++++++++ 8 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 app/controllers/my/connected_apps_controller.rb create mode 100644 app/views/my/connected_apps/_connected_app.html.erb create mode 100644 app/views/my/connected_apps/index.html.erb create mode 100644 app/views/users/_connected_apps.html.erb create mode 100644 test/controllers/my/connected_apps_controller_test.rb diff --git a/app/controllers/my/connected_apps_controller.rb b/app/controllers/my/connected_apps_controller.rb new file mode 100644 index 0000000000..322451db7e --- /dev/null +++ b/app/controllers/my/connected_apps_controller.rb @@ -0,0 +1,28 @@ +class My::ConnectedAppsController < ApplicationController + before_action :set_connected_apps, only: :index + before_action :set_oauth_client, only: :destroy + + def index + end + + def destroy + @tokens.destroy_all + + redirect_to my_connected_apps_path, notice: "#{@client.name} has been disconnected" + end + + private + def set_connected_apps + tokens = oauth_tokens.includes(:oauth_client).order(:created_at) + @connected_apps = tokens.group_by(&:oauth_client).sort_by { |client, _| client.name.downcase } + end + + def set_oauth_client + @tokens = oauth_tokens.where(oauth_client_id: params.require(:id)) + @client = @tokens.first&.oauth_client or raise ActiveRecord::RecordNotFound + end + + def oauth_tokens + Current.identity.access_tokens.oauth + end +end diff --git a/app/views/my/connected_apps/_connected_app.html.erb b/app/views/my/connected_apps/_connected_app.html.erb new file mode 100644 index 0000000000..33d63ce439 --- /dev/null +++ b/app/views/my/connected_apps/_connected_app.html.erb @@ -0,0 +1,13 @@ + + <%= client.name %> + <%= tokens.map { |t| t.permission.humanize }.uniq.sort.join(", ") %> + <%= local_datetime_tag tokens.map(&:created_at).min, style: :datetime %> + + <%= button_to my_connected_app_path(client), method: :delete, + class: "btn txt-negative btn--circle txt-x-small borderless fill-transparent", + data: { turbo_confirm: "Disconnect #{client.name}? This will revoke all access." } do %> + <%= icon_tag "trash" %> + Disconnect this app + <% end %> + + diff --git a/app/views/my/connected_apps/index.html.erb b/app/views/my/connected_apps/index.html.erb new file mode 100644 index 0000000000..75db574ba6 --- /dev/null +++ b/app/views/my/connected_apps/index.html.erb @@ -0,0 +1,32 @@ +<% @page_title = "Connected apps" %> + +<% content_for :header do %> +
+ <%= back_link_to "My profile", user_path(Current.user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> +
+ +

<%= @page_title %>

+<% end %> + +
+ <% if @connected_apps.any? %> +

Apps you've authorized to access Fizzy on your behalf.

+ + + + + + + + + + + <% @connected_apps.each do |client, tokens| %> + <%= render "my/connected_apps/connected_app", client: client, tokens: tokens %> + <% end %> + +
AppPermissionsConnected
+ <% else %> +

No apps are connected to your account. When you authorize an app to access Fizzy, it will appear here.

+ <% end %> +
diff --git a/app/views/users/_access_tokens.html.erb b/app/views/users/_access_tokens.html.erb index 75bffa95c9..e1fa9544df 100644 --- a/app/views/users/_access_tokens.html.erb +++ b/app/views/users/_access_tokens.html.erb @@ -3,4 +3,4 @@

Developer

Manage <%= link_to "personal access tokens", my_access_tokens_path, class: "btn btn--plain txt-link" %> used with the Fizzy developer API.
- + \ No newline at end of file diff --git a/app/views/users/_connected_apps.html.erb b/app/views/users/_connected_apps.html.erb new file mode 100644 index 0000000000..fd9411198d --- /dev/null +++ b/app/views/users/_connected_apps.html.erb @@ -0,0 +1,4 @@ +
+

Connected Apps

+

Manage <%= link_to "apps you've authorized", my_connected_apps_path, class: "btn btn--plain txt-link" %> to access your account.

+
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index a50a24a981..a6e941d269 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -59,6 +59,7 @@
<%= render "users/theme" %> <%= render "users/transfer", user: @user %> + <%= render "users/connected_apps" if Current.identity.access_tokens.oauth.exists? %> <%= render "users/access_tokens" %> <%= render "users/data_export" %>
diff --git a/config/routes.rb b/config/routes.rb index 1cd885e215..14c93a9320 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -191,6 +191,7 @@ resource :passkey_challenge, only: :create resource :identity, only: :show resources :access_tokens + resources :connected_apps, only: %i[ index destroy ] resources :passkeys, except: %i[ show new ] resources :pins resource :timezone diff --git a/test/controllers/my/connected_apps_controller_test.rb b/test/controllers/my/connected_apps_controller_test.rb new file mode 100644 index 0000000000..d67ba5b17c --- /dev/null +++ b/test/controllers/my/connected_apps_controller_test.rb @@ -0,0 +1,61 @@ +require "test_helper" + +class My::ConnectedAppsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :david + @identity = identities(:david) + end + + test "index shows connected OAuth apps" do + client = oauth_clients(:mcp_client) + @identity.access_tokens.create!(oauth_client: client, permission: :read) + + get my_connected_apps_path + assert_response :success + assert_match client.name, response.body + end + + test "index excludes PATs" do + # PAT has no oauth_client + @identity.access_tokens.create!(permission: :read, description: "My PAT") + + get my_connected_apps_path + assert_response :success + assert_no_match "My PAT", response.body + end + + test "destroy revokes all tokens for a client" do + client = oauth_clients(:mcp_client) + @identity.access_tokens.create!(oauth_client: client, permission: :read) + @identity.access_tokens.create!(oauth_client: client, permission: :write) + + assert_difference "Identity::AccessToken.count", -2 do + delete my_connected_app_path(client) + end + + assert_redirected_to my_connected_apps_path + assert_match "disconnected", flash[:notice] + end + + test "destroy only revokes tokens for the specified client" do + client1 = oauth_clients(:mcp_client) + client2 = Oauth::Client.create!(name: "Other App", redirect_uris: %w[ http://127.0.0.1/cb ]) + + @identity.access_tokens.create!(oauth_client: client1, permission: :read) + @identity.access_tokens.create!(oauth_client: client2, permission: :read) + + assert_difference "Identity::AccessToken.count", -1 do + delete my_connected_app_path(client1) + end + + assert @identity.access_tokens.exists?(oauth_client: client2) + end + + test "destroy returns 404 for unconnected client" do + client = oauth_clients(:mcp_client) + # No tokens for this client + + delete my_connected_app_path(client) + assert_response :not_found + end +end From 62797f03d83d92d10486f776284a938575e2129f Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 27 Aug 2026 23:38:51 -0700 Subject: [PATCH 04/26] Accept form-encoded requests at the OAuth protocol endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conforming OAuth clients post token, revocation, and registration requests as application/x-www-form-urlencoded, but the app's forgery protection rejects headerless non-JSON POSTs over https, answering 422 before the endpoint runs. These are cookie-free server-to-server endpoints, so CSRF doesn't apply — skip it. The browser-facing authorization consent form keeps its protection. Covered by a form-encoded exchange test with forgery protection active. --- app/controllers/oauth/clients_controller.rb | 1 + .../oauth/revocations_controller.rb | 1 + app/controllers/oauth/tokens_controller.rb | 1 + test/integration/oauth_flow_test.rb | 35 +++++++++++++++++++ 4 files changed, 38 insertions(+) diff --git a/app/controllers/oauth/clients_controller.rb b/app/controllers/oauth/clients_controller.rb index df0c13f1a6..9d73cf7a22 100644 --- a/app/controllers/oauth/clients_controller.rb +++ b/app/controllers/oauth/clients_controller.rb @@ -1,5 +1,6 @@ class Oauth::ClientsController < Oauth::BaseController allow_unauthenticated_access + skip_forgery_protection rate_limit to: 10, within: 1.minute, only: :create, with: :oauth_rate_limit_exceeded diff --git a/app/controllers/oauth/revocations_controller.rb b/app/controllers/oauth/revocations_controller.rb index 6dca9777d3..5e49c5d1e9 100644 --- a/app/controllers/oauth/revocations_controller.rb +++ b/app/controllers/oauth/revocations_controller.rb @@ -1,5 +1,6 @@ class Oauth::RevocationsController < Oauth::BaseController allow_unauthenticated_access + skip_forgery_protection before_action :set_access_token diff --git a/app/controllers/oauth/tokens_controller.rb b/app/controllers/oauth/tokens_controller.rb index 11cfefce0b..b5eba3e188 100644 --- a/app/controllers/oauth/tokens_controller.rb +++ b/app/controllers/oauth/tokens_controller.rb @@ -1,5 +1,6 @@ class Oauth::TokensController < Oauth::BaseController allow_unauthenticated_access + skip_forgery_protection rate_limit to: 20, within: 1.minute, only: :create, with: :oauth_rate_limit_exceeded diff --git a/test/integration/oauth_flow_test.rb b/test/integration/oauth_flow_test.rb index b52d354163..dde50c470c 100644 --- a/test/integration/oauth_flow_test.rb +++ b/test/integration/oauth_flow_test.rb @@ -270,6 +270,33 @@ class OauthFlowTest < ActionDispatch::IntegrationTest end + test "token endpoint accepts form-encoded requests with forgery protection active" do + code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + + code = Oauth::AuthorizationCode.generate \ + client_id: oauth_clients(:mcp_client).client_id, + identity_id: identities(:david).id, + code_challenge: code_challenge, + redirect_uri: "http://127.0.0.1:8888/callback", + scope: "read" + + with_forgery_protection do + untenanted do + post oauth_token_path, params: { + grant_type: "authorization_code", + code: code, + redirect_uri: "http://127.0.0.1:8888/callback", + code_verifier: code_verifier + }, headers: { "X-Forwarded-Proto" => "https" } + end + end + + assert_response :success + assert_not_nil response.parsed_body["access_token"] + end + + # Token Revocation (RFC 7009) test "revocation deletes access token" do @@ -434,4 +461,12 @@ class OauthFlowTest < ActionDispatch::IntegrationTest assert_not_nil body["access_token"] assert_equal "Bearer", body["token_type"] end + + private + def with_forgery_protection + ActionController::Base.allow_forgery_protection = true + yield + ensure + ActionController::Base.allow_forgery_protection = false + end end From 130bd7914dc274635a3bc7aa571940c5cfd0506c Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 5 Oct 2026 19:22:54 -0700 Subject: [PATCH 05/26] Redeem each authorization code once, for the client it was issued to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authorization codes are stateless encrypted blobs, so nothing stopped one from being redeemed again within its 60s lifetime, minting another independent grant each time. RFC 6749 §4.1.2 says the server MUST deny a second use and SHOULD revoke what the first one issued. Each code now carries a jti, and the grant it mints is stamped with it under a unique index on identity_access_tokens. A second redemption fails the insert, answers invalid_grant, and destroys the grant from the first. The database index is the single-use record: it is atomic on the primary, needs no other store, and the stamp is what lets a replay find the grant to revoke. Reuse is only detected after PKCE, redirect_uri and client_id pass, so someone holding a leaked code alone can't revoke the grant. Codes minted before this deploy carry no jti and are rejected; they expire within 60s anyway. The code grant also now requires client_id and checks the code was issued to it (RFC 6749 §4.1.3). Before, the client came from the code alone, so client_id could be omitted or name another client. Missing grant_type, code, code_verifier, redirect_uri or client_id answers invalid_request (RFC 6749 §5.2), not unsupported_grant_type or invalid_grant, which a client would read as a dead grant and discard. --- app/controllers/oauth/tokens_controller.rb | 37 +++++-- app/models/identity/access_token.rb | 12 ++ app/models/oauth/authorization_code.rb | 11 +- ...tion_code_jti_to_identity_access_tokens.rb | 6 + db/schema.rb | 2 + db/schema_sqlite.rb | 2 + test/integration/oauth_flow_test.rb | 104 ++++++++++++++++++ test/models/oauth/authorization_code_test.rb | 36 +++++- 8 files changed, 195 insertions(+), 15 deletions(-) create mode 100644 db/migrate/20260827090000_add_authorization_code_jti_to_identity_access_tokens.rb diff --git a/app/controllers/oauth/tokens_controller.rb b/app/controllers/oauth/tokens_controller.rb index b5eba3e188..683b517897 100644 --- a/app/controllers/oauth/tokens_controller.rb +++ b/app/controllers/oauth/tokens_controller.rb @@ -5,8 +5,10 @@ class Oauth::TokensController < Oauth::BaseController rate_limit to: 20, within: 1.minute, only: :create, with: :oauth_rate_limit_exceeded before_action :validate_grant_type + before_action :require_params before_action :set_auth_code before_action :set_client + before_action :validate_client_id before_action :validate_pkce before_action :validate_redirect_uri before_action :set_identity @@ -14,22 +16,36 @@ class Oauth::TokensController < Oauth::BaseController def create granted = @auth_code.scope.to_s.split permission = granted.include?("write") ? "write" : "read" - access_token = @identity.access_tokens.create! oauth_client: @client, permission: permission - render json: { - access_token: access_token.token, - token_type: "Bearer", - scope: granted.join(" ") - } + if access_token = @identity.access_tokens.redeem(@auth_code, oauth_client: @client, permission: permission) + render json: { + access_token: access_token.token, + token_type: "Bearer", + scope: granted.join(" ") + } + else + oauth_error "invalid_grant", "Authorization code already used" + end end private def validate_grant_type - unless params[:grant_type] == "authorization_code" + if params[:grant_type].blank? + oauth_error "invalid_request", "Missing required parameter: grant_type" + elsif params[:grant_type] != "authorization_code" oauth_error "unsupported_grant_type", "Only authorization_code grant is supported" end end + # 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. + def require_params + if (missing = %w[ code code_verifier redirect_uri client_id ].select { |name| params[name].blank? }).any? + oauth_error "invalid_request", "Missing required parameter: #{missing.to_sentence}" + end + end + def set_auth_code unless @auth_code = Oauth::AuthorizationCode.parse(params[:code]) oauth_error "invalid_grant", "Invalid or expired authorization code" @@ -42,6 +58,13 @@ def set_client end end + # RFC 6749 §4.1.3: the code must have been issued to the client_id in the request. + def validate_client_id + unless params[:client_id] == @client.client_id + oauth_error "invalid_grant", "Authorization code was not issued to this client" + end + end + def validate_pkce unless Oauth::AuthorizationCode.valid_pkce?(@auth_code, params[:code_verifier]) oauth_error "invalid_grant", "PKCE verification failed" diff --git a/app/models/identity/access_token.rb b/app/models/identity/access_token.rb index 6cd1fc3b24..6d13a63d26 100644 --- a/app/models/identity/access_token.rb +++ b/app/models/identity/access_token.rb @@ -8,6 +8,18 @@ class Identity::AccessToken < ApplicationRecord has_secure_token enum :permission, %w[ read write ].index_by(&:itself), default: :read + class << self + # An authorization code redeems at most once (RFC 6749 §4.1.2): the grant it + # mints is stamped with the code's jti, and the unique index refuses a second + # stamp. A replay also revokes the grant the first redemption issued. + def redeem(authorization_code, **attributes) + create! authorization_code_jti: authorization_code.jti, **attributes + rescue ActiveRecord::RecordNotUnique + where(authorization_code_jti: authorization_code.jti).destroy_all + nil + end + end + def allows?(method) method.in?(%w[ GET HEAD ]) || write? end diff --git a/app/models/oauth/authorization_code.rb b/app/models/oauth/authorization_code.rb index 32460b57f1..eff12d7ee3 100644 --- a/app/models/oauth/authorization_code.rb +++ b/app/models/oauth/authorization_code.rb @@ -1,20 +1,23 @@ +# Codes are stateless, so single use is enforced where they are redeemed: each +# carries a jti that the grant it mints is stamped with (Identity::AccessToken.redeem). module Oauth::AuthorizationCode - Details = ::Data.define(:client_id, :identity_id, :code_challenge, :redirect_uri, :scope) + Details = ::Data.define(:client_id, :identity_id, :code_challenge, :redirect_uri, :scope, :jti) class << self def generate(client_id:, identity_id:, code_challenge:, redirect_uri:, scope:) - payload = { client_id:, identity_id:, code_challenge:, redirect_uri:, scope: } + payload = { client_id:, identity_id:, code_challenge:, redirect_uri:, scope:, jti: SecureRandom.uuid } encryptor.encrypt_and_sign(payload, expires_in: 60.seconds) end def parse(code) - if code.present? && data = encryptor.decrypt_and_verify(code) + if code.present? && (data = encryptor.decrypt_and_verify(code)) && data["jti"].present? Details.new \ client_id: data["client_id"], identity_id: data["identity_id"], code_challenge: data["code_challenge"], redirect_uri: data["redirect_uri"], - scope: data["scope"] + scope: data["scope"], + jti: data["jti"] end rescue ActiveSupport::MessageEncryptor::InvalidMessage, ActiveSupport::MessageVerifier::InvalidSignature nil diff --git a/db/migrate/20260827090000_add_authorization_code_jti_to_identity_access_tokens.rb b/db/migrate/20260827090000_add_authorization_code_jti_to_identity_access_tokens.rb new file mode 100644 index 0000000000..75bf6a3112 --- /dev/null +++ b/db/migrate/20260827090000_add_authorization_code_jti_to_identity_access_tokens.rb @@ -0,0 +1,6 @@ +class AddAuthorizationCodeJtiToIdentityAccessTokens < ActiveRecord::Migration[8.2] + def change + add_column :identity_access_tokens, :authorization_code_jti, :string + add_index :identity_access_tokens, :authorization_code_jti, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 387c954a90..5747aa5db5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -352,6 +352,7 @@ end create_table "identity_access_tokens", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "authorization_code_jti" t.datetime "created_at", null: false t.text "description" t.uuid "identity_id", null: false @@ -359,6 +360,7 @@ t.string "permission" t.string "token" t.datetime "updated_at", null: false + t.index ["authorization_code_jti"], name: "index_identity_access_tokens_on_authorization_code_jti", unique: true t.index ["identity_id"], name: "index_access_token_on_identity_id" t.index ["oauth_client_id"], name: "index_identity_access_tokens_on_oauth_client_id" end diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index 06258b424b..4a57506cd9 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -352,6 +352,7 @@ end create_table "identity_access_tokens", id: :uuid, force: :cascade do |t| + t.string "authorization_code_jti", limit: 255 t.datetime "created_at", null: false t.text "description", limit: 65535 t.uuid "identity_id", null: false @@ -359,6 +360,7 @@ t.string "permission", limit: 255 t.string "token", limit: 255 t.datetime "updated_at", null: false + t.index ["authorization_code_jti"], name: "index_identity_access_tokens_on_authorization_code_jti", unique: true t.index ["identity_id"], name: "index_access_token_on_identity_id" t.index ["oauth_client_id"], name: "index_identity_access_tokens_on_oauth_client_id" end diff --git a/test/integration/oauth_flow_test.rb b/test/integration/oauth_flow_test.rb index dde50c470c..01353b2769 100644 --- a/test/integration/oauth_flow_test.rb +++ b/test/integration/oauth_flow_test.rb @@ -146,6 +146,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest untenanted do post oauth_token_path, params: { grant_type: "authorization_code", + client_id: oauth_clients(:mcp_client).client_id, code: code, redirect_uri: "http://127.0.0.1:8888/callback", code_verifier: code_verifier @@ -170,6 +171,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest untenanted do post oauth_token_path, params: { grant_type: "authorization_code", + client_id: oauth_clients(:mcp_client).client_id, code: "invalid_code", redirect_uri: "http://127.0.0.1/cb", code_verifier: "verifier" @@ -196,6 +198,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest untenanted do post oauth_token_path, params: { grant_type: "authorization_code", + client_id: oauth_clients(:mcp_client).client_id, code: code, redirect_uri: "http://127.0.0.1:8888/callback", code_verifier: "wrong_verifier" @@ -222,6 +225,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest untenanted do post oauth_token_path, params: { grant_type: "authorization_code", + client_id: oauth_clients(:mcp_client).client_id, code: code, redirect_uri: "http://127.0.0.1:9999/different", code_verifier: code_verifier @@ -249,6 +253,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest untenanted do post oauth_token_path, params: { grant_type: "authorization_code", + client_id: oauth_clients(:mcp_client).client_id, code: code, redirect_uri: "http://127.0.0.1:8888/callback", code_verifier: code_verifier @@ -269,6 +274,94 @@ class OauthFlowTest < ActionDispatch::IntegrationTest assert_equal "unsupported_grant_type", response.parsed_body["error"] end + test "token exchange requires grant_type" do + untenanted do + post oauth_token_path, params: { code: "code" }, as: :json + end + + assert_response :bad_request + assert_equal "invalid_request", response.parsed_body["error"] + end + + test "token exchange requires code, code_verifier, redirect_uri and client_id" do + code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + client = oauth_clients(:mcp_client) + code = authorization_code_for(client, code_verifier: code_verifier) + complete = { grant_type: "authorization_code", code: code, code_verifier: code_verifier, + redirect_uri: "http://127.0.0.1:8888/callback", client_id: client.client_id } + + %i[ code code_verifier redirect_uri client_id ].each do |name| + assert_no_difference "Identity::AccessToken.count" do + untenanted { post oauth_token_path, params: complete.except(name), as: :json } + end + + assert_response :bad_request + assert_equal "invalid_request", response.parsed_body["error"], "missing #{name}" + assert_match name.to_s, response.parsed_body["error_description"] + end + end + + test "token exchange rejects a code issued to another client" do + code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + code = authorization_code_for(oauth_clients(:mcp_client), code_verifier: code_verifier) + + assert_no_difference "Identity::AccessToken.count" do + untenanted do + post oauth_token_path, params: { + grant_type: "authorization_code", + client_id: oauth_clients(:trusted_client).client_id, + code: code, + redirect_uri: "http://127.0.0.1:8888/callback", + code_verifier: code_verifier + }, as: :json + end + end + + assert_response :bad_request + assert_equal "invalid_grant", response.parsed_body["error"] + end + + test "token exchange rejects a reused code and revokes the grant it issued" do + code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + client = oauth_clients(:mcp_client) + code = authorization_code_for(client, code_verifier: code_verifier) + exchange = { grant_type: "authorization_code", client_id: client.client_id, code: code, + redirect_uri: "http://127.0.0.1:8888/callback", code_verifier: code_verifier } + + untenanted { post oauth_token_path, params: exchange, as: :json } + assert_response :success + first_token = response.parsed_body["access_token"] + assert Identity::AccessToken.exists?(token: first_token) + + assert_difference "Identity::AccessToken.count", -1 do + untenanted { post oauth_token_path, params: exchange, as: :json } + end + + assert_response :bad_request + assert_equal "invalid_grant", response.parsed_body["error"] + assert_not Identity::AccessToken.exists?(token: first_token) + + fresh_code = authorization_code_for(client, code_verifier: code_verifier) + untenanted { post oauth_token_path, params: exchange.merge(code: fresh_code), as: :json } + assert_response :success + end + + test "a replayed code that fails PKCE does not revoke the grant" do + code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + client = oauth_clients(:mcp_client) + code = authorization_code_for(client, code_verifier: code_verifier) + exchange = { grant_type: "authorization_code", client_id: client.client_id, code: code, + redirect_uri: "http://127.0.0.1:8888/callback", code_verifier: code_verifier } + + untenanted { post oauth_token_path, params: exchange, as: :json } + assert_response :success + first_token = response.parsed_body["access_token"] + + untenanted { post oauth_token_path, params: exchange.merge(code_verifier: "wrong_verifier"), as: :json } + assert_response :bad_request + assert Identity::AccessToken.exists?(token: first_token) + end + test "token endpoint accepts form-encoded requests with forgery protection active" do code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" @@ -285,6 +378,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest untenanted do post oauth_token_path, params: { grant_type: "authorization_code", + client_id: oauth_clients(:mcp_client).client_id, code: code, redirect_uri: "http://127.0.0.1:8888/callback", code_verifier: code_verifier @@ -449,6 +543,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest untenanted do post oauth_token_path, params: { grant_type: "authorization_code", + client_id: oauth_clients(:mcp_client).client_id, code: code, redirect_uri: "http://127.0.0.1:8888/callback", code_verifier: code_verifier @@ -463,6 +558,15 @@ class OauthFlowTest < ActionDispatch::IntegrationTest end private + def authorization_code_for(client, code_verifier:, identity: identities(:david), scope: "read") + Oauth::AuthorizationCode.generate \ + client_id: client.client_id, + identity_id: identity.id, + code_challenge: Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false), + redirect_uri: "http://127.0.0.1:8888/callback", + scope: scope + end + def with_forgery_protection ActionController::Base.allow_forgery_protection = true yield diff --git a/test/models/oauth/authorization_code_test.rb b/test/models/oauth/authorization_code_test.rb index 67610bcee2..3c0746b7a8 100644 --- a/test/models/oauth/authorization_code_test.rb +++ b/test/models/oauth/authorization_code_test.rb @@ -31,6 +31,30 @@ class Oauth::AuthorizationCodeTest < ActiveSupport::TestCase assert_equal "read write", parsed.scope end + test "each code carries its own jti" do + codes = 2.times.map do + Oauth::AuthorizationCode.generate \ + client_id: "test_client", + identity_id: 456, + code_challenge: "challenge_hash", + redirect_uri: "http://127.0.0.1:8888/callback", + scope: "read" + end + + jtis = codes.map { |code| Oauth::AuthorizationCode.parse(code).jti } + assert jtis.all?(&:present?) + assert_not_equal jtis.first, jtis.last + end + + test "parse returns nil for a code without a jti" do + encryptor = ActiveSupport::MessageEncryptor.new \ + Rails.application.key_generator.generate_key("oauth/authorization_codes", 32) + code = encryptor.encrypt_and_sign({ client_id: "test_client", identity_id: 456, code_challenge: "challenge_hash", + redirect_uri: "http://127.0.0.1:8888/callback", scope: "read" }, expires_in: 60.seconds) + + assert_nil Oauth::AuthorizationCode.parse(code) + end + test "parse returns nil for blank code" do assert_nil Oauth::AuthorizationCode.parse("") assert_nil Oauth::AuthorizationCode.parse(nil) @@ -89,7 +113,8 @@ class Oauth::AuthorizationCodeTest < ActiveSupport::TestCase identity_id: 1, code_challenge: code_challenge, redirect_uri: "http://127.0.0.1/cb", - scope: "read" + scope: "read", + jti: "test-jti" assert Oauth::AuthorizationCode.valid_pkce?(details, code_verifier) end @@ -103,7 +128,8 @@ class Oauth::AuthorizationCodeTest < ActiveSupport::TestCase identity_id: 1, code_challenge: code_challenge, redirect_uri: "http://127.0.0.1/cb", - scope: "read" + scope: "read", + jti: "test-jti" assert_not Oauth::AuthorizationCode.valid_pkce?(details, "wrong_verifier") end @@ -118,7 +144,8 @@ class Oauth::AuthorizationCodeTest < ActiveSupport::TestCase identity_id: 1, code_challenge: "challenge", redirect_uri: "http://127.0.0.1/cb", - scope: "read" + scope: "read", + jti: "test-jti" assert_not Oauth::AuthorizationCode.valid_pkce?(details, "") assert_not Oauth::AuthorizationCode.valid_pkce?(details, nil) @@ -130,7 +157,8 @@ class Oauth::AuthorizationCodeTest < ActiveSupport::TestCase identity_id: 1, code_challenge: "challenge", redirect_uri: "http://127.0.0.1/cb", - scope: "read" + scope: "read", + jti: "test-jti" assert_raises(FrozenError) { details.instance_variable_set(:@client_id, "hacked") } end From 14bce347d394d0241adf8925f08d635288498c7e Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 5 Oct 2026 19:51:21 -0700 Subject: [PATCH 06/26] Let consent redirect past CSP, name the issuer, and grant canonical scopes Consent form-action: browsers hold a form submission's redirects to the page's form-action, so the app-wide 'self' made Chrome block Authorize and Deny from reaching the client. The authorization pages now also allow this request's validated redirect origin (port included, for loopback clients), or the bare scheme where CSP can't name the host (IPv6 literals, native schemes). Ported from bc3; covered by a Chrome system test that fails without it. RFC 9207: success and error redirects carry iss, from the same helper as the metadata issuer, and the metadata advertises authorization_response_iss_parameter_supported. MCP clients talk to many authorization servers; this is their mix-up defense. Scope: a grant that includes write is "read write" everywhere. A request for "read write" used to preselect Read on the consent screen, so a user who just clicked Authorize got a read-only grant, and a write grant was echoed as "write" alone. DCR registering write registers read too. --- .../oauth/authorizations_controller.rb | 40 +++- app/controllers/oauth/base_controller.rb | 6 + app/controllers/oauth/clients_controller.rb | 2 +- app/controllers/oauth/metadata_controller.rb | 5 +- .../protected_resource_metadata_controller.rb | 2 +- app/controllers/oauth/tokens_controller.rb | 6 +- app/models/oauth.rb | 6 + app/views/oauth/authorizations/new.html.erb | 2 +- test/integration/oauth_flow_test.rb | 194 ++++++++++++++++++ test/system/oauth_consent_test.rb | 19 ++ 10 files changed, 270 insertions(+), 12 deletions(-) create mode 100644 test/system/oauth_consent_test.rb diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb index bb605e0acb..515cdbc660 100644 --- a/app/controllers/oauth/authorizations_controller.rb +++ b/app/controllers/oauth/authorizations_controller.rb @@ -1,4 +1,14 @@ class Oauth::AuthorizationsController < Oauth::BaseController + FORM_ACTION_ORIGIN = %r{\Ahttps?://[a-z0-9.-]+(:\d+)?\z}i + FORM_ACTION_SCHEME = /\A[a-z][a-z0-9+.-]*\z/i + + # Browsers apply form-action to every hop of a form submission, redirects + # included, so the app-wide form-action 'self' would block the consent POST's + # redirect to the client. Allow just this request's validated redirect target. + content_security_policy if: -> { request.content_security_policy } do |policy| + policy.form_action :self, -> { validated_redirect_form_action_source } + end + before_action :save_oauth_return_url before_action :require_authentication @@ -10,7 +20,7 @@ class Oauth::AuthorizationsController < Oauth::BaseController before_action :validate_state def new - @scope = params[:scope].presence || "read" + @scope = Oauth.canonical_scope(params[:scope].presence || "read") @redirect_uri = params[:redirect_uri] @state = params[:state] @code_challenge = params[:code_challenge] @@ -25,7 +35,7 @@ def create identity_id: Current.identity.id, code_challenge: params[:code_challenge], redirect_uri: params[:redirect_uri], - scope: params[:scope].presence || "read" + scope: Oauth.canonical_scope(params[:scope].presence || "read") redirect_to success_redirect_uri(code), allow_other_host: true end @@ -88,14 +98,36 @@ def redirect_with_error(error, description) def success_redirect_uri(code) build_redirect_uri params[:redirect_uri], code: code, - state: params[:state].presence + state: params[:state].presence, + iss: oauth_issuer end def error_redirect_uri(error, description) build_redirect_uri params[:redirect_uri], error: error, error_description: description, - state: params[:state].presence + state: params[:state].presence, + iss: oauth_issuer + end + + # A CSP source naming the validated redirect origin, port included, since a + # loopback client may present any port (RFC 8252 §7.3). CSP host-sources + # can't express IPv6 literals, and a registered native redirect has no + # host, so those get their bare scheme. Nothing outside these shapes is + # written into the header. + def validated_redirect_form_action_source + if @client&.allows_redirect?(params[:redirect_uri]) + uri = URI.parse(params[:redirect_uri]) + origin = "#{uri.scheme}://#{uri.host}#{":#{uri.port}" unless uri.port == uri.default_port}" + + if origin.match?(FORM_ACTION_ORIGIN) + origin + elsif uri.scheme.to_s.match?(FORM_ACTION_SCHEME) + "#{uri.scheme}:" + end + end + rescue URI::InvalidURIError + nil end def build_redirect_uri(base, **query_params) diff --git a/app/controllers/oauth/base_controller.rb b/app/controllers/oauth/base_controller.rb index 658d1e2d48..500ae94007 100644 --- a/app/controllers/oauth/base_controller.rb +++ b/app/controllers/oauth/base_controller.rb @@ -2,6 +2,12 @@ class Oauth::BaseController < ApplicationController disallow_account_scope private + # The metadata issuer and the iss authorization response parameter must be + # identical (RFC 9207 §2). script_name: nil keeps an account prefix out of it. + def oauth_issuer + root_url(script_name: nil) + 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 9d73cf7a22..a9baae2a55 100644 --- a/app/controllers/oauth/clients_controller.rb +++ b/app/controllers/oauth/clients_controller.rb @@ -59,7 +59,7 @@ def validated_scopes when Array then params[:scope].select { |s| s.is_a?(String) } else [] end - requested.select { |s| s.presence_in %w[ read write ] }.presence || %w[ read ] + Oauth.canonical_scope(requested.join(" ")).split end def dynamic_client_registration_response(client) diff --git a/app/controllers/oauth/metadata_controller.rb b/app/controllers/oauth/metadata_controller.rb index 9bc84ea15e..1d177ff95b 100644 --- a/app/controllers/oauth/metadata_controller.rb +++ b/app/controllers/oauth/metadata_controller.rb @@ -3,7 +3,7 @@ class Oauth::MetadataController < Oauth::BaseController def show render json: { - issuer: root_url(script_name: nil), + issuer: oauth_issuer, authorization_endpoint: new_oauth_authorization_url, token_endpoint: oauth_token_url, registration_endpoint: oauth_clients_url, @@ -12,7 +12,8 @@ def show grant_types_supported: %w[ authorization_code ], token_endpoint_auth_methods_supported: %w[ none ], code_challenge_methods_supported: %w[ S256 ], - scopes_supported: %w[ read write ] + scopes_supported: %w[ read write ], + authorization_response_iss_parameter_supported: true } end end diff --git a/app/controllers/oauth/protected_resource_metadata_controller.rb b/app/controllers/oauth/protected_resource_metadata_controller.rb index d7f6d4895d..c0a19e0eb3 100644 --- a/app/controllers/oauth/protected_resource_metadata_controller.rb +++ b/app/controllers/oauth/protected_resource_metadata_controller.rb @@ -4,7 +4,7 @@ class Oauth::ProtectedResourceMetadataController < Oauth::BaseController def show render json: { resource: root_url(script_name: nil), - authorization_servers: [ root_url(script_name: nil) ], + authorization_servers: [ oauth_issuer ], bearer_methods_supported: %w[ header ], scopes_supported: %w[ read write ] } diff --git a/app/controllers/oauth/tokens_controller.rb b/app/controllers/oauth/tokens_controller.rb index 683b517897..a2ce792927 100644 --- a/app/controllers/oauth/tokens_controller.rb +++ b/app/controllers/oauth/tokens_controller.rb @@ -14,14 +14,14 @@ class Oauth::TokensController < Oauth::BaseController before_action :set_identity def create - granted = @auth_code.scope.to_s.split - permission = granted.include?("write") ? "write" : "read" + granted = Oauth.canonical_scope(@auth_code.scope) + permission = granted.split.include?("write") ? "write" : "read" if access_token = @identity.access_tokens.redeem(@auth_code, oauth_client: @client, permission: permission) render json: { access_token: access_token.token, token_type: "Bearer", - scope: granted.join(" ") + scope: granted } else oauth_error "invalid_grant", "Authorization code already used" diff --git a/app/models/oauth.rb b/app/models/oauth.rb index 5770882e85..f824442f28 100644 --- a/app/models/oauth.rb +++ b/app/models/oauth.rb @@ -1,6 +1,12 @@ module Oauth LOOPBACK_HOSTS = %w[ 127.0.0.1 localhost ::1 [::1] ] + # Write permits every request, reads included, so a grant that includes + # write is reported as "read write" wherever it is shown or echoed. + def self.canonical_scope(scope) + scope.to_s.split.include?("write") ? "read write" : "read" + end + def self.table_name_prefix "oauth_" end diff --git a/app/views/oauth/authorizations/new.html.erb b/app/views/oauth/authorizations/new.html.erb index c1ce809dc8..a37a0d24e5 100644 --- a/app/views/oauth/authorizations/new.html.erb +++ b/app/views/oauth/authorizations/new.html.erb @@ -23,7 +23,7 @@
<%= f.label :scope, "Permission" %> - <% scope_options = @client.scopes.include?("write") ? { "Read" => "read", "Read + Write" => "write" } : { "Read" => "read" } %> + <% scope_options = @client.scopes.include?("write") ? { "Read" => "read", "Read + Write" => "read write" } : { "Read" => "read" } %> <%= f.select :scope, options_for_select(scope_options, @scope), {}, class: "input input--select" %>
diff --git a/test/integration/oauth_flow_test.rb b/test/integration/oauth_flow_test.rb index 01353b2769..2c38d85b34 100644 --- a/test/integration/oauth_flow_test.rb +++ b/test/integration/oauth_flow_test.rb @@ -126,6 +126,138 @@ class OauthFlowTest < ActionDispatch::IntegrationTest end + test "authorization preselects Read + Write when the client requests read write" do + sign_in_as :david + + get_consent_screen scope: "read write" + + assert_response :success + assert_select "select[name=scope] option[selected]", count: 1 + assert_select "select[name=scope] option[selected][value=?]", "read write" + end + + test "authorization preselects Read + Write when the client requests write" do + sign_in_as :david + + get_consent_screen scope: "write" + + assert_response :success + assert_select "select[name=scope] option[selected][value=?]", "read write" + end + + test "authorization preselects Read for a read request" do + sign_in_as :david + + get_consent_screen scope: "read" + + assert_response :success + assert_select "select[name=scope] option[selected][value=?]", "read" + end + + test "authorization offers no write option to a read-only client" do + sign_in_as :david + client = Oauth::Client.create!(name: "Reader", redirect_uris: %w[ http://127.0.0.1:8888/callback ], scopes: %w[ read ], dynamically_registered: true) + + get_consent_screen client: client, scope: "read" + + assert_response :success + assert_select "select[name=scope] option", count: 1 + assert_select "select[name=scope] option[value=?]", "read" + end + + test "consent grants the canonical read write scope" do + sign_in_as :david + code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + + post_consent scope: "read write", code_challenge: Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + code = Rack::Utils.parse_query(URI.parse(response.location).query)["code"] + + untenanted do + post oauth_token_path, params: { + grant_type: "authorization_code", + client_id: oauth_clients(:mcp_client).client_id, + code: code, + redirect_uri: "http://127.0.0.1:8888/callback", + code_verifier: code_verifier + }, as: :json + end + + assert_response :success + assert_equal "read write", response.parsed_body["scope"] + assert_equal "write", Identity::AccessToken.find_by(token: response.parsed_body["access_token"]).permission + end + + test "authorization redirects carry the issuer from the metadata (RFC 9207)" do + sign_in_as :david + + untenanted { get "/.well-known/oauth-authorization-server" } + issuer = response.parsed_body["issuer"] + assert_predicate issuer, :present? + + post_consent + assert_equal issuer, Rack::Utils.parse_query(URI.parse(response.location).query)["iss"] + + post_consent error: "access_denied" + denied = Rack::Utils.parse_query(URI.parse(response.location).query) + assert_equal "access_denied", denied["error"] + assert_equal issuer, denied["iss"] + + get_consent_screen code_challenge: nil + assert_response :redirect + invalid = Rack::Utils.parse_query(URI.parse(response.location).query) + assert_equal "invalid_request", invalid["error"] + assert_equal issuer, invalid["iss"] + end + + test "authorization redirect keeps the registered query alongside iss" do + sign_in_as :david + client = Oauth::Client.create!(name: "Query", redirect_uris: %w[ https://app.example.com/cb?tenant=acme ], scopes: %w[ read ]) + + post_consent client: client, redirect_uri: "https://app.example.com/cb?tenant=acme" + + query = Rack::Utils.parse_query(URI.parse(response.location).query) + assert_equal "acme", query["tenant"] + assert_equal "http://www.example.com/", query["iss"] + end + + test "consent screen lets the form redirect to the validated loopback origin" do + sign_in_as :david + + get_consent_screen redirect_uri: "http://127.0.0.1:53682/callback" + + assert_response :success + assert_equal [ "'self'", "http://127.0.0.1:53682" ], form_action_sources + end + + test "consent screen lets the form redirect to the validated https origin" do + sign_in_as :david + + get_consent_screen client: oauth_clients(:trusted_client), redirect_uri: "https://app.example.com/oauth/callback" + + assert_response :success + assert_equal [ "'self'", "https://app.example.com" ], form_action_sources + end + + test "consent screen allows only the http scheme for an IPv6 loopback, which CSP cannot name" do + sign_in_as :david + client = Oauth::Client.create!(name: "IPv6", redirect_uris: %w[ http://[::1]:8888/callback ], dynamically_registered: true) + + get_consent_screen client: client, redirect_uri: "http://[::1]:9999/callback" + + assert_response :success + assert_equal [ "'self'", "http:" ], form_action_sources + end + + test "an unvalidated redirect_uri adds nothing to form-action" do + sign_in_as :david + + get_consent_screen redirect_uri: "http://evil.com/steal" + + assert_response :bad_request + assert_equal [ "'self'" ], form_action_sources + end + + # Token Endpoint test "token exchange with valid code and PKCE" do @@ -167,6 +299,24 @@ class OauthFlowTest < ActionDispatch::IntegrationTest assert_equal "read", token.permission end + test "token exchange reports a legacy write scope canonically" do + code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + client = oauth_clients(:mcp_client) + + untenanted do + post oauth_token_path, params: { + grant_type: "authorization_code", + client_id: client.client_id, + code: authorization_code_for(client, code_verifier: code_verifier, scope: "write"), + redirect_uri: "http://127.0.0.1:8888/callback", + code_verifier: code_verifier + }, as: :json + end + + assert_response :success + assert_equal "read write", response.parsed_body["scope"] + end + test "token exchange rejects invalid code" do untenanted do post oauth_token_path, params: { @@ -436,6 +586,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest assert_match %r{/oauth/clients$}, body["registration_endpoint"] assert_includes body["response_types_supported"], "code" assert_includes body["code_challenge_methods_supported"], "S256" + assert_equal true, body["authorization_response_iss_parameter_supported"] end test "protected resource metadata includes authorization server" do @@ -471,6 +622,20 @@ class OauthFlowTest < ActionDispatch::IntegrationTest assert_equal [ "http://127.0.0.1:8888/callback" ], body["redirect_uris"] end + test "DCR registering write also registers read, which write implies" do + untenanted do + post oauth_clients_path, params: { + client_name: "Writer", + redirect_uris: [ "http://127.0.0.1:8888/callback" ], + scope: "write" + }, as: :json + end + + assert_response :created + assert_equal "read write", response.parsed_body["scope"] + assert_equal %w[ read write ], Oauth::Client.find_by(client_id: response.parsed_body["client_id"]).scopes + end + test "DCR rejects non-loopback redirect" do assert_no_difference "Oauth::Client.count" do untenanted do @@ -558,6 +723,35 @@ class OauthFlowTest < ActionDispatch::IntegrationTest end private + def get_consent_screen(client: oauth_clients(:mcp_client), **params) + untenanted do + get new_oauth_authorization_path, params: consent_params(client, **params).compact + end + end + + def post_consent(client: oauth_clients(:mcp_client), **params) + untenanted do + post oauth_authorization_path, params: consent_params(client, **params).compact + end + end + + def consent_params(client, **overrides) + { + client_id: client.client_id, + redirect_uri: client.redirect_uris.first, + response_type: "code", + code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + code_challenge_method: "S256", + scope: "read", + state: "xyz123" + }.merge(overrides) + end + + def form_action_sources + response.headers["Content-Security-Policy"].split(";").map(&:strip) \ + .find { |directive| directive.start_with?("form-action ") }.split.drop(1) + end + def authorization_code_for(client, code_verifier:, identity: identities(:david), scope: "read") Oauth::AuthorizationCode.generate \ client_id: client.client_id, diff --git a/test/system/oauth_consent_test.rb b/test/system/oauth_consent_test.rb new file mode 100644 index 0000000000..a3dd95f64d --- /dev/null +++ b/test/system/oauth_consent_test.rb @@ -0,0 +1,19 @@ +require "application_system_test_case" + +class OauthConsentTest < ApplicationSystemTestCase + # The consent form posts to us and we redirect to the client. Browsers hold + # that redirect to the consent page's form-action, so this needs a real one. + test "authorizing redirects the browser to a loopback client" do + client = Oauth::Client.create! name: "Loopback Tool", dynamically_registered: true, + redirect_uris: %w[ http://localhost/oauth-test-callback ] + callback = "http://localhost:#{Capybara.current_session.server.port}/oauth-test-callback" + + sign_in_as users(:david) + visit new_oauth_authorization_url(script_name: nil, client_id: client.client_id, redirect_uri: callback, + response_type: "code", code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + code_challenge_method: "S256", scope: "read", state: "xyz123") + click_on "Authorize" + + assert_current_path %r{\A#{Regexp.escape(callback)}\?.*code=}, url: true + end +end From d36b5c2a800d594c19cbdc66aa11705564502139 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 5 Oct 2026 20:25:08 -0700 Subject: [PATCH 07/26] Describe revocation and response modes honestly, bound client names, and vary only the loopback port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Metadata advertises revocation_endpoint_auth_methods_supported [none] and response_modes_supported [query]. Omitted, RFC 8414 implies client_secret_basic and [query fragment], neither of which this server does. - DCR client_name must be a String (others fall back to the default) and at most 255 characters, so an oversized name answers invalid_client_metadata instead of a 500 from the column limit (RFC 7591 §3.2.2). - Loopback redirects match on the exact host and query; only the port varies (RFC 8252 §7.3). 127.0.0.1, localhost and ::1 are no longer interchangeable. - Revocation without a token answers invalid_request JSON (RFC 7009 §2.2.1) rather than Rails' generic ParameterMissing 400. --- app/controllers/oauth/clients_controller.rb | 6 +- app/controllers/oauth/metadata_controller.rb | 2 + .../oauth/revocations_controller.rb | 6 +- app/models/oauth/client.rb | 10 ++- test/integration/oauth_flow_test.rb | 68 +++++++++++++++++++ test/models/oauth/client_test.rb | 24 ++++++- 6 files changed, 110 insertions(+), 6 deletions(-) diff --git a/app/controllers/oauth/clients_controller.rb b/app/controllers/oauth/clients_controller.rb index a9baae2a55..c95d21ae3d 100644 --- a/app/controllers/oauth/clients_controller.rb +++ b/app/controllers/oauth/clients_controller.rb @@ -10,7 +10,7 @@ class Oauth::ClientsController < Oauth::BaseController def create client = Oauth::Client.create! \ - name: params[:client_name] || "MCP Client", + name: registered_client_name, redirect_uris: Array(params[:redirect_uris]), scopes: validated_scopes, dynamically_registered: true @@ -53,6 +53,10 @@ def valid_loopback_uri?(uri) false end + def registered_client_name + (params[:client_name] if params[:client_name].is_a?(String)).presence || "MCP Client" + end + def validated_scopes requested = case params[:scope] when String then params[:scope].split diff --git a/app/controllers/oauth/metadata_controller.rb b/app/controllers/oauth/metadata_controller.rb index 1d177ff95b..aa500529de 100644 --- a/app/controllers/oauth/metadata_controller.rb +++ b/app/controllers/oauth/metadata_controller.rb @@ -8,7 +8,9 @@ def show token_endpoint: oauth_token_url, registration_endpoint: oauth_clients_url, revocation_endpoint: oauth_revocation_url, + revocation_endpoint_auth_methods_supported: %w[ none ], response_types_supported: %w[ code ], + response_modes_supported: %w[ query ], grant_types_supported: %w[ authorization_code ], token_endpoint_auth_methods_supported: %w[ none ], code_challenge_methods_supported: %w[ S256 ], diff --git a/app/controllers/oauth/revocations_controller.rb b/app/controllers/oauth/revocations_controller.rb index 5e49c5d1e9..733f386a20 100644 --- a/app/controllers/oauth/revocations_controller.rb +++ b/app/controllers/oauth/revocations_controller.rb @@ -12,6 +12,10 @@ def create private def set_access_token - @access_token = Identity::AccessToken.find_by(token: params.require(:token)) + if (token = params[:token]).present? + @access_token = Identity::AccessToken.find_by(token: token) + else + oauth_error "invalid_request", "token is required" + end end end diff --git a/app/models/oauth/client.rb b/app/models/oauth/client.rb index dd29156169..7531ae90a9 100644 --- a/app/models/oauth/client.rb +++ b/app/models/oauth/client.rb @@ -3,7 +3,7 @@ class Oauth::Client < ApplicationRecord has_secure_token :client_id, length: 32 - validates :name, presence: true + validates :name, presence: true, length: { maximum: 255 } validates :client_id, uniqueness: true, allow_nil: true validates :redirect_uris, presence: true validate :redirect_uris_are_valid @@ -57,6 +57,9 @@ def valid_loopback_uri?(parsed) parsed.scheme == "http" && parsed.host.in?(Oauth::LOOPBACK_HOSTS) end + # Only the port may vary (RFC 8252 §7.3). The host must match too: + # 127.0.0.1, localhost and ::1 are not interchangeable, and localhost + # may not even resolve to loopback (RFC 8252 §8.3). def matching_loopback?(uri) parsed = URI.parse(uri) @@ -65,8 +68,9 @@ def matching_loopback?(uri) redirect.scheme == parsed.scheme && redirect.host.in?(Oauth::LOOPBACK_HOSTS) && - parsed.host.in?(Oauth::LOOPBACK_HOSTS) && - redirect.path == parsed.path + redirect.host.casecmp?(parsed.host.to_s) && + redirect.path == parsed.path && + redirect.query == parsed.query end rescue URI::InvalidURIError false diff --git a/test/integration/oauth_flow_test.rb b/test/integration/oauth_flow_test.rb index 2c38d85b34..57a56278a0 100644 --- a/test/integration/oauth_flow_test.rb +++ b/test/integration/oauth_flow_test.rb @@ -567,6 +567,33 @@ class OauthFlowTest < ActionDispatch::IntegrationTest post oauth_revocation_path, params: { token: "" }, as: :json end assert_response :bad_request + assert_equal "invalid_request", response.parsed_body["error"] + end + + test "revocation without a token answers invalid_request, form-encoded or JSON" do + untenanted do + post oauth_revocation_path, params: { token_type_hint: "access_token" } + end + assert_response :bad_request + assert_equal "invalid_request", response.parsed_body["error"] + + untenanted do + post oauth_revocation_path, params: {}, as: :json + end + assert_response :bad_request + 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 @@ -587,6 +614,9 @@ class OauthFlowTest < ActionDispatch::IntegrationTest assert_includes body["response_types_supported"], "code" assert_includes body["code_challenge_methods_supported"], "S256" 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"] end test "protected resource metadata includes authorization server" do @@ -650,6 +680,44 @@ class OauthFlowTest < ActionDispatch::IntegrationTest assert_equal "invalid_redirect_uri", response.parsed_body["error"] end + test "DCR rejects a client_name longer than 255 characters" do + assert_no_difference "Oauth::Client.count" do + untenanted do + post oauth_clients_path, params: { + client_name: "a" * 256, + redirect_uris: [ "http://127.0.0.1:8888/callback" ] + }, as: :json + end + end + + assert_response :bad_request + assert_equal "invalid_client_metadata", response.parsed_body["error"] + end + + test "DCR accepts a 255-character client_name" do + untenanted do + post oauth_clients_path, params: { + client_name: "a" * 255, + redirect_uris: [ "http://127.0.0.1:8888/callback" ] + }, as: :json + end + + assert_response :created + assert_equal "a" * 255, response.parsed_body["client_name"] + end + + test "DCR falls back to the default name for a non-string client_name" do + untenanted do + post oauth_clients_path, params: { + client_name: { "en" => "Sneaky" }, + redirect_uris: [ "http://127.0.0.1:8888/callback" ] + }, as: :json + end + + assert_response :created + assert_equal "MCP Client", response.parsed_body["client_name"] + 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 55108b564e..cea59aac08 100644 --- a/test/models/oauth/client_test.rb +++ b/test/models/oauth/client_test.rb @@ -102,7 +102,29 @@ class Oauth::ClientTest < ActiveSupport::TestCase test "allows_redirect? allows different ports for loopback clients" do client = Oauth::Client.new(redirect_uris: %w[ http://127.0.0.1:8888/callback ]) assert client.allows_redirect?("http://127.0.0.1:9999/callback") - assert client.allows_redirect?("http://localhost:7777/callback") + end + + test "allows_redirect? lets only the port vary for loopback clients" do + client = Oauth::Client.new(redirect_uris: %w[ http://127.0.0.1:8888/callback ]) + assert_not client.allows_redirect?("http://localhost:7777/callback") + assert_not client.allows_redirect?("http://[::1]:7777/callback") + assert_not client.allows_redirect?("http://127.0.0.1:9999/callback?x=1") + + localhost = Oauth::Client.new(redirect_uris: %w[ http://localhost:8888/callback ]) + assert localhost.allows_redirect?("http://localhost:9999/callback") + + with_query = Oauth::Client.new(redirect_uris: %w[ http://127.0.0.1:8888/callback?app=cli ]) + assert with_query.allows_redirect?("http://127.0.0.1:9999/callback?app=cli") + assert_not with_query.allows_redirect?("http://127.0.0.1:9999/callback") + assert_not with_query.allows_redirect?("http://127.0.0.1:9999/callback?app=cli&code=injected") + end + + test "name is limited to 255 characters" do + assert Oauth::Client.new(name: "a" * 255, redirect_uris: %w[ http://127.0.0.1/cb ]).valid? + + client = Oauth::Client.new(name: "a" * 256, redirect_uris: %w[ http://127.0.0.1/cb ]) + assert_not client.valid? + assert client.errors[:name].any? end test "allows_redirect? requires matching path for loopback flexibility" do From 266e9480cff12654642312f50bd375ab80f5b99c Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 5 Oct 2026 20:34:34 -0700 Subject: [PATCH 08/26] Ship the OAuth authorization server dark behind availability switches Merging this branch must not light OAuth on fizzy.do or in any OSS install. Port bc3's Oauth::Availability, trimmed to what Fizzy has: - acceptance (OAUTH_ACCEPTANCE_ENABLED): fail-closed, anything but true is dark. While dark, discovery, registration, authorization and the token endpoint answer 404, and OAuth-issued bearer tokens are refused. Personal access tokens are untouched. - issuance (OAUTH_ISSUANCE_ENABLED=false): the incident pause, subordinate to acceptance. Discovery stays up, minting answers 503, existing tokens keep working. - pilot_client_ids (OAUTH_PILOT_CLIENT_IDS): named clients are exempt from both switches, so first-party software can pilot against a dark server. Discovery and registration name no client and stay dark. Revocation, Connected Apps and consent denials are never gated, so people can always shed access. Lit in development (OAUTH_DARK=1 previews dark) and test; production, beta and staging read the environment. --- .../oauth/authorizations_controller.rb | 9 +- app/controllers/oauth/base_controller.rb | 19 ++ app/controllers/oauth/clients_controller.rb | 1 + app/controllers/oauth/metadata_controller.rb | 2 + .../protected_resource_metadata_controller.rb | 2 + app/controllers/oauth/tokens_controller.rb | 1 + app/models/identity.rb | 2 +- app/models/identity/access_token.rb | 7 + app/models/oauth/availability.rb | 68 +++++ config/environments/development.rb | 4 + config/environments/production.rb | 10 + config/environments/test.rb | 3 + docs/docker-deployment.md | 8 + test/integration/oauth_availability_test.rb | 249 ++++++++++++++++++ test/models/oauth/availability_test.rb | 53 ++++ .../oauth_availability_test_helper.rb | 13 + 16 files changed, 449 insertions(+), 2 deletions(-) create mode 100644 app/models/oauth/availability.rb create mode 100644 test/integration/oauth_availability_test.rb create mode 100644 test/models/oauth/availability_test.rb create mode 100644 test/test_helpers/oauth_availability_test_helper.rb diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb index 515cdbc660..ac5cf461af 100644 --- a/app/controllers/oauth/authorizations_controller.rb +++ b/app/controllers/oauth/authorizations_controller.rb @@ -9,6 +9,9 @@ class Oauth::AuthorizationsController < Oauth::BaseController policy.form_action :self, -> { validated_redirect_form_action_source } end + # Gated ahead of sign-in so a dark server never asks anyone to sign in for + # it. A denial is never gated: it only tells the client no. + before_action :require_issuance_enabled, unless: :denial? before_action :save_oauth_return_url before_action :require_authentication @@ -27,7 +30,7 @@ def new end def create - if params[:error] == "access_denied" + if denial? redirect_to error_redirect_uri("access_denied", "User denied the request"), allow_other_host: true else code = Oauth::AuthorizationCode.generate \ @@ -42,6 +45,10 @@ def create end private + def denial? + request.post? && params[:error] == "access_denied" + end + def save_oauth_return_url session[:return_to_after_authenticating] = request.url if request.get? && !authenticated? end diff --git a/app/controllers/oauth/base_controller.rb b/app/controllers/oauth/base_controller.rb index 500ae94007..d2197ffd8a 100644 --- a/app/controllers/oauth/base_controller.rb +++ b/app/controllers/oauth/base_controller.rb @@ -2,6 +2,25 @@ class Oauth::BaseController < ApplicationController disallow_account_scope private + # Discovery follows acceptance (see Oauth::Availability): a dark server + # answers as if it had no authorization server at all. + def require_acceptance_enabled + head :not_found unless Oauth::Availability.acceptance_enabled? + end + + # Minting endpoints are 404 while dark and 503 while issuance is paused. + # Revocation, Connected Apps and denials never call this, so people can + # always shed access. The pilot exemption reads the claimed client_id, + # before the client authenticates: this only decides whether the endpoint + # answers at all. + def require_issuance_enabled + if !Oauth::Availability.acceptance_enabled?(params[:client_id]) + head :not_found + elsif !Oauth::Availability.issuance_enabled?(params[:client_id]) + head :service_unavailable + end + end + # The metadata issuer and the iss authorization response parameter must be # identical (RFC 9207 §2). script_name: nil keeps an account prefix out of it. def oauth_issuer diff --git a/app/controllers/oauth/clients_controller.rb b/app/controllers/oauth/clients_controller.rb index c95d21ae3d..c7a984f2af 100644 --- a/app/controllers/oauth/clients_controller.rb +++ b/app/controllers/oauth/clients_controller.rb @@ -4,6 +4,7 @@ class Oauth::ClientsController < Oauth::BaseController rate_limit to: 10, within: 1.minute, only: :create, with: :oauth_rate_limit_exceeded + before_action :require_issuance_enabled before_action :validate_redirect_uris before_action :validate_loopback_uris before_action :validate_auth_method diff --git a/app/controllers/oauth/metadata_controller.rb b/app/controllers/oauth/metadata_controller.rb index aa500529de..08eea40a58 100644 --- a/app/controllers/oauth/metadata_controller.rb +++ b/app/controllers/oauth/metadata_controller.rb @@ -1,6 +1,8 @@ class Oauth::MetadataController < Oauth::BaseController allow_unauthenticated_access + before_action :require_acceptance_enabled + def show render json: { issuer: oauth_issuer, diff --git a/app/controllers/oauth/protected_resource_metadata_controller.rb b/app/controllers/oauth/protected_resource_metadata_controller.rb index c0a19e0eb3..0fa8767efa 100644 --- a/app/controllers/oauth/protected_resource_metadata_controller.rb +++ b/app/controllers/oauth/protected_resource_metadata_controller.rb @@ -1,6 +1,8 @@ class Oauth::ProtectedResourceMetadataController < Oauth::BaseController allow_unauthenticated_access + before_action :require_acceptance_enabled + def show render json: { resource: root_url(script_name: nil), diff --git a/app/controllers/oauth/tokens_controller.rb b/app/controllers/oauth/tokens_controller.rb index a2ce792927..13b0eaeae9 100644 --- a/app/controllers/oauth/tokens_controller.rb +++ b/app/controllers/oauth/tokens_controller.rb @@ -4,6 +4,7 @@ class Oauth::TokensController < Oauth::BaseController rate_limit to: 20, within: 1.minute, only: :create, with: :oauth_rate_limit_exceeded + before_action :require_issuance_enabled before_action :validate_grant_type before_action :require_params before_action :set_auth_code diff --git a/app/models/identity.rb b/app/models/identity.rb index 752544d928..3ddfb79142 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -17,7 +17,7 @@ class Identity < ApplicationRecord normalizes :email_address, with: ->(value) { value.strip.downcase.presence } def self.find_by_permissable_access_token(token, method:) - if (access_token = AccessToken.find_by(token: token)) && access_token.allows?(method) + if (access_token = AccessToken.find_by(token: token)) && access_token.honored? && access_token.allows?(method) access_token.identity end end diff --git a/app/models/identity/access_token.rb b/app/models/identity/access_token.rb index 6d13a63d26..a00687868d 100644 --- a/app/models/identity/access_token.rb +++ b/app/models/identity/access_token.rb @@ -20,6 +20,13 @@ def redeem(authorization_code, **attributes) end end + # OAuth tokens are honored only while OAuth acceptance is on (see + # Oauth::Availability); personal tokens always are. The client is loaded only + # when the server is dark and a pilot exemption might apply. + def honored? + oauth_client_id.nil? || Oauth::Availability.acceptance_enabled? || Oauth::Availability.acceptance_enabled?(oauth_client&.client_id) + end + def allows?(method) method.in?(%w[ GET HEAD ]) || write? end diff --git a/app/models/oauth/availability.rb b/app/models/oauth/availability.rb new file mode 100644 index 0000000000..e275b54fb3 --- /dev/null +++ b/app/models/oauth/availability.rb @@ -0,0 +1,68 @@ +# Availability switches for the OAuth authorization server, so it can ship dark. +# +# - acceptance: whether Fizzy honors the OAuth tokens it issued +# - issuance: whether Fizzy mints new credentials (client registrations, +# authorization codes, tokens) +# +# Issuance is subordinate to acceptance: Fizzy never issues a credential it +# would refuse to accept. Personal access tokens are not OAuth credentials and +# neither switch touches them. +# +# Acceptance is fail-closed: anything but an explicit true is dark, so an +# install that never mentions OAuth (every OSS install, and SaaS until it is +# lit) answers as if it had no authorization server. Issuance is the incident +# pause, so it follows acceptance unless explicitly turned off. See +# config/environments/production.rb for the environment variables. +# +# Discovery follows acceptance: a server that honors tokens stays discoverable +# while issuance is paused (minting endpoints answer 503); a dark server answers +# 404 to discovery and minting alike. +# +# Management is never gated: revocation, Connected Apps and authorization +# denials stay reachable while dark or paused, so people can always shed access. +# +# == Piloting against a dark server +# +# Named clients, by exact client_id, are exempt from both switches so +# first-party software can exercise the real server before it opens to +# everyone. Only surfaces that know which client is asking honor it: discovery +# names no client and stays dark, and so does registration, so a pilot uses an +# operator-provisioned client. The exemption reads the claimed client_id before +# the client authenticates; it decides only whether the endpoint answers, and +# leaving with a credential still takes the registered redirect_uri, PKCE, a +# signed-in user's consent and, for a confidential client, its secret. +# +# An incident pause must therefore empty pilot_client_ids as well as throwing +# the switch, or listed clients keep minting through it. +module Oauth::Availability + extend self + + def acceptance_enabled?(client_id = nil) + accepting? || piloted?(client_id) + end + + def issuance_enabled?(client_id = nil) + acceptance_enabled?(client_id) && (issuing? || piloted?(client_id)) + end + + private + def accepting? + config.acceptance_enabled == true + end + + def issuing? + config.issuance_enabled != false + end + + def piloted?(client_id) + client_id.is_a?(String) && client_id.present? && pilot_client_ids.include?(client_id) + end + + def pilot_client_ids + Array(config.pilot_client_ids) + end + + def config + Rails.application.config.x.oauth + end +end diff --git a/config/environments/development.rb b/config/environments/development.rb index 730a945dbd..bad753c8f3 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -95,6 +95,10 @@ /.*\.nip\.io/ # nip.io for mobile apps ] + # OAuth is lit in development; preview the dark posture with OAUTH_DARK=1. + # See Oauth::Availability. + config.x.oauth.acceptance_enabled = ENV["OAUTH_DARK"].blank? + # Canonical host for mailer URLs (emails always link here, not personal Tailscale URLs) config.action_mailer.default_url_options = { host: "#{config.hosts.first}:3006" } end diff --git a/config/environments/production.rb b/config/environments/production.rb index d2db6a0078..5437a54f60 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -119,6 +119,16 @@ # Do not dump schema after migrations. config.active_record.dump_schema_after_migration = false + # The OAuth authorization server ships dark: unless OAUTH_ACCEPTANCE_ENABLED is + # "true" there is no discovery, registration, authorization or token endpoint, + # and OAuth-issued tokens are refused. OAUTH_ISSUANCE_ENABLED=false pauses + # minting while existing tokens keep working. OAUTH_PILOT_CLIENT_IDS + # (comma-separated client_ids) exempts named clients from both switches. + # See Oauth::Availability. Applies to beta and staging, which load this file. + config.x.oauth.acceptance_enabled = ENV["OAUTH_ACCEPTANCE_ENABLED"] == "true" + config.x.oauth.issuance_enabled = ENV["OAUTH_ISSUANCE_ENABLED"] != "false" + config.x.oauth.pilot_client_ids = ENV.fetch("OAUTH_PILOT_CLIENT_IDS", "").split(",").map(&:strip).compact_blank + # Skip DNS rebinding protection for the default health check endpoint. # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } end diff --git a/config/environments/test.rb b/config/environments/test.rb index b80f2607b9..f405825b57 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -68,6 +68,9 @@ # Load test helpers config.autoload_paths += %w[ test/test_helpers ] + # OAuth is lit in tests; see OauthAvailabilityTestHelper to go dark. + config.x.oauth.acceptance_enabled = true + # Enable multi-tenant mode for tests config.x.multi_tenant.enabled = true diff --git a/docs/docker-deployment.md b/docs/docker-deployment.md index 30543f005c..cac422129b 100644 --- a/docs/docker-deployment.md +++ b/docs/docker-deployment.md @@ -153,6 +153,14 @@ This is for convenience: typically when you self-host you'll be running a single If you do want to allow multiple accounts to be created in your instance, set `MULTI_TENANT=true` +#### OAuth (optional) + +Fizzy includes an OAuth 2.1 authorization server so that tools such as MCP clients can connect to your account without a personal access token. It is off by default: while it is off, the OAuth discovery, registration, authorization and token endpoints answer 404, and tokens issued through OAuth are not accepted. Personal access tokens work either way. + +To turn it on, set `OAUTH_ACCEPTANCE_ENABLED=true`. + +If you need to stop new connections without breaking existing ones, also set `OAUTH_ISSUANCE_ENABLED=false`: connected apps keep working, but nothing new can connect. People can always disconnect apps from their profile, whichever way these are set. + ## Importing an existing Fizzy account You can move an account between Fizzy instances by exporting it on the old instance and uploading the export zip to the new one during signup. diff --git a/test/integration/oauth_availability_test.rb b/test/integration/oauth_availability_test.rb new file mode 100644 index 0000000000..3f1adb9392 --- /dev/null +++ b/test/integration/oauth_availability_test.rb @@ -0,0 +1,249 @@ +require "test_helper" + +class OauthAvailabilityTest < ActionDispatch::IntegrationTest + include OauthAvailabilityTestHelper + + CODE_VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + + # Discovery follows acceptance + + test "a dark server publishes no discovery documents" do + with_oauth_availability acceptance: false do + untenanted { get "/.well-known/oauth-authorization-server" } + assert_response :not_found + + untenanted { get "/.well-known/oauth-protected-resource" } + assert_response :not_found + end + end + + test "a pilot client does not make a dark server discoverable" do + with_oauth_availability acceptance: false, pilot_client_ids: [ oauth_clients(:mcp_client).client_id ] do + untenanted { get "/.well-known/oauth-authorization-server", params: { client_id: oauth_clients(:mcp_client).client_id } } + assert_response :not_found + end + end + + test "pausing issuance keeps discovery up" do + with_oauth_availability acceptance: true, issuance: false do + untenanted { get "/.well-known/oauth-authorization-server" } + assert_response :success + + untenanted { get "/.well-known/oauth-protected-resource" } + assert_response :success + end + end + + # Issuance + + test "a dark server registers no clients" do + with_oauth_availability acceptance: false do + assert_no_difference "Oauth::Client.count" do + untenanted { post oauth_clients_path, params: { redirect_uris: [ "http://127.0.0.1:9999/callback" ] }, as: :json } + end + end + + assert_response :not_found + end + + test "pausing issuance refuses registration with 503" do + with_oauth_availability acceptance: true, issuance: false do + assert_no_difference "Oauth::Client.count" do + untenanted { post oauth_clients_path, params: { redirect_uris: [ "http://127.0.0.1:9999/callback" ] }, as: :json } + end + end + + assert_response :service_unavailable + end + + test "a dark server does not ask a signed-out user to sign in to authorize" do + with_oauth_availability acceptance: false do + untenanted { get new_oauth_authorization_path, params: authorization_params } + end + + assert_response :not_found + end + + test "a dark server shows no consent screen" do + sign_in_as :david + + with_oauth_availability acceptance: false do + untenanted { get new_oauth_authorization_path, params: authorization_params } + end + + assert_response :not_found + end + + test "a dark server issues no code on approval" do + sign_in_as :david + + with_oauth_availability acceptance: false do + untenanted { post oauth_authorization_path, params: authorization_params } + end + + assert_response :not_found + end + + test "pausing issuance refuses authorization with 503" do + sign_in_as :david + + with_oauth_availability acceptance: true, issuance: false do + untenanted { get new_oauth_authorization_path, params: authorization_params } + assert_response :service_unavailable + + untenanted { post oauth_authorization_path, params: authorization_params } + assert_response :service_unavailable + end + end + + test "a denial still reaches the client while dark" do + sign_in_as :david + + with_oauth_availability acceptance: false, issuance: false do + untenanted { post oauth_authorization_path, params: authorization_params.merge(error: "access_denied") } + end + + assert_response :redirect + assert_match %r{\Ahttp://127\.0\.0\.1:8888/callback\?error=access_denied}, response.location + end + + test "a dark server mints no tokens" do + with_oauth_availability acceptance: false do + assert_no_difference "Identity::AccessToken.count" do + untenanted { post oauth_token_path, params: token_params, as: :json } + end + end + + assert_response :not_found + end + + test "pausing issuance refuses the token endpoint with 503" do + with_oauth_availability acceptance: true, issuance: false do + assert_no_difference "Identity::AccessToken.count" do + untenanted { post oauth_token_path, params: token_params, as: :json } + end + end + + assert_response :service_unavailable + end + + test "a pilot client completes the flow against a dark server" do + sign_in_as :david + client = oauth_clients(:mcp_client) + + with_oauth_availability acceptance: false, issuance: false, pilot_client_ids: [ client.client_id ] do + untenanted { get new_oauth_authorization_path, params: authorization_params } + assert_response :success + + untenanted { post oauth_authorization_path, params: authorization_params } + assert_response :redirect + + untenanted { post oauth_token_path, params: token_params, as: :json } + assert_response :success + + get user_path(users(:david)), env: bearer(response.parsed_body["access_token"]), as: :json + assert_response :success + end + end + + test "another client is still refused while a pilot runs" do + with_oauth_availability acceptance: false, pilot_client_ids: [ oauth_clients(:trusted_client).client_id ] do + untenanted { post oauth_token_path, params: token_params, as: :json } + end + + assert_response :not_found + end + + # Acceptance + + test "OAuth bearer tokens are refused while acceptance is off" do + token = identities(:david).access_tokens.create!(oauth_client: oauth_clients(:mcp_client), permission: :read) + + with_oauth_availability acceptance: false do + get user_path(users(:david)), env: bearer(token.token), as: :json + end + + assert_response :unauthorized + end + + test "personal access tokens still work while OAuth is dark" do + with_oauth_availability acceptance: false do + get user_path(users(:jason)), env: bearer(identity_access_tokens(:jasons_api_token).token), as: :json + end + + assert_response :success + end + + test "pausing issuance keeps existing OAuth tokens working" do + token = identities(:david).access_tokens.create!(oauth_client: oauth_clients(:mcp_client), permission: :read) + + with_oauth_availability acceptance: true, issuance: false do + get user_path(users(:david)), env: bearer(token.token), as: :json + end + + assert_response :success + end + + # Management is never gated + + test "revocation works while dark" do + token = identities(:david).access_tokens.create!(oauth_client: oauth_clients(:mcp_client), permission: :read) + + with_oauth_availability acceptance: false, issuance: false do + assert_difference "Identity::AccessToken.count", -1 do + untenanted { post oauth_revocation_path, params: { token: token.token } } + end + end + + assert_response :success + end + + test "Connected Apps lists and disconnects while dark" do + sign_in_as :david + client = oauth_clients(:mcp_client) + identities(:david).access_tokens.create!(oauth_client: client, permission: :read) + + with_oauth_availability acceptance: false, issuance: false do + get my_connected_apps_path + assert_response :success + assert_match client.name, response.body + + assert_difference "Identity::AccessToken.count", -1 do + delete my_connected_app_path(client) + end + assert_redirected_to my_connected_apps_path + end + 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: Base64.urlsafe_encode64(Digest::SHA256.digest(CODE_VERIFIER), padding: false), + code_challenge_method: "S256", + scope: "read", + state: "xyz123" + } + end + + def token_params + { + grant_type: "authorization_code", + client_id: oauth_clients(:mcp_client).client_id, + code: Oauth::AuthorizationCode.generate( + client_id: oauth_clients(:mcp_client).client_id, + identity_id: identities(:david).id, + code_challenge: authorization_params[:code_challenge], + redirect_uri: "http://127.0.0.1:8888/callback", + scope: "read"), + redirect_uri: "http://127.0.0.1:8888/callback", + code_verifier: CODE_VERIFIER + } + end + + def bearer(token) + { "HTTP_AUTHORIZATION" => "Bearer #{token}" } + end +end diff --git a/test/models/oauth/availability_test.rb b/test/models/oauth/availability_test.rb new file mode 100644 index 0000000000..33cdd931ec --- /dev/null +++ b/test/models/oauth/availability_test.rb @@ -0,0 +1,53 @@ +require "test_helper" + +class Oauth::AvailabilityTest < ActiveSupport::TestCase + include OauthAvailabilityTestHelper + + test "lit in test" do + assert Oauth::Availability.acceptance_enabled? + assert Oauth::Availability.issuance_enabled? + end + + test "an unset acceptance switch is dark" do + with_oauth_availability acceptance: nil, issuance: nil do + assert_not Oauth::Availability.acceptance_enabled? + assert_not Oauth::Availability.issuance_enabled? + end + end + + test "acceptance must be exactly true" do + with_oauth_availability acceptance: "false" do + assert_not Oauth::Availability.acceptance_enabled? + end + end + + test "issuance follows acceptance unless paused" do + with_oauth_availability acceptance: true, issuance: nil do + assert Oauth::Availability.issuance_enabled? + end + + with_oauth_availability acceptance: true, issuance: false do + assert Oauth::Availability.acceptance_enabled? + assert_not Oauth::Availability.issuance_enabled? + end + end + + test "issuance is subordinate to acceptance" do + with_oauth_availability acceptance: false, issuance: true do + assert_not Oauth::Availability.issuance_enabled? + end + end + + test "a pilot client clears both switches, and only by its exact client_id" do + with_oauth_availability acceptance: false, issuance: false, pilot_client_ids: [ "pilot_1" ] do + assert Oauth::Availability.acceptance_enabled?("pilot_1") + assert Oauth::Availability.issuance_enabled?("pilot_1") + + assert_not Oauth::Availability.acceptance_enabled? + assert_not Oauth::Availability.issuance_enabled?("pilot_2") + assert_not Oauth::Availability.issuance_enabled?("PILOT_1") + assert_not Oauth::Availability.issuance_enabled?([ "pilot_1" ]) + assert_not Oauth::Availability.issuance_enabled?("") + end + end +end diff --git a/test/test_helpers/oauth_availability_test_helper.rb b/test/test_helpers/oauth_availability_test_helper.rb new file mode 100644 index 0000000000..6860126319 --- /dev/null +++ b/test/test_helpers/oauth_availability_test_helper.rb @@ -0,0 +1,13 @@ +module OauthAvailabilityTestHelper + def with_oauth_availability(acceptance: true, issuance: true, pilot_client_ids: []) + config = Rails.application.config.x.oauth + saved = config.to_h.slice(:acceptance_enabled, :issuance_enabled, :pilot_client_ids) + + config.acceptance_enabled = acceptance + config.issuance_enabled = issuance + config.pilot_client_ids = pilot_client_ids + yield + ensure + %i[ acceptance_enabled issuance_enabled pilot_client_ids ].each { |key| config[key] = saved[key] } + end +end From 8c8eb937973b6c0671397c096e8a9c42a09dd98b Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 5 Oct 2026 20:40:31 -0700 Subject: [PATCH 09/26] Keep registration dark when a request names a pilot client Registration creates a client rather than acting for one, so a client_id it carries must not earn the pilot exemption. Without this, anyone who knew a pilot's client_id could register clients on a dark server. --- app/controllers/oauth/base_controller.rb | 9 +++++++-- app/controllers/oauth/clients_controller.rb | 6 ++++++ test/integration/oauth_availability_test.rb | 10 ++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/app/controllers/oauth/base_controller.rb b/app/controllers/oauth/base_controller.rb index d2197ffd8a..21e253ece4 100644 --- a/app/controllers/oauth/base_controller.rb +++ b/app/controllers/oauth/base_controller.rb @@ -14,13 +14,18 @@ def require_acceptance_enabled # before the client authenticates: this only decides whether the endpoint # answers at all. def require_issuance_enabled - if !Oauth::Availability.acceptance_enabled?(params[:client_id]) + if !Oauth::Availability.acceptance_enabled?(piloting_client_id) head :not_found - elsif !Oauth::Availability.issuance_enabled?(params[:client_id]) + elsif !Oauth::Availability.issuance_enabled?(piloting_client_id) head :service_unavailable end end + # The client a request acts for, if it names one. + def piloting_client_id + params[:client_id] + end + # The metadata issuer and the iss authorization response parameter must be # identical (RFC 9207 §2). script_name: nil keeps an account prefix out of it. def oauth_issuer diff --git a/app/controllers/oauth/clients_controller.rb b/app/controllers/oauth/clients_controller.rb index c7a984f2af..a897f3ad21 100644 --- a/app/controllers/oauth/clients_controller.rb +++ b/app/controllers/oauth/clients_controller.rb @@ -22,6 +22,12 @@ def create end private + # A registration creates a client rather than acting for one, so a + # client_id it carries earns no pilot exemption: registration stays dark. + def piloting_client_id + nil + end + def validate_redirect_uris unless performed? || params[:redirect_uris].present? oauth_error "invalid_client_metadata", "redirect_uris is required" diff --git a/test/integration/oauth_availability_test.rb b/test/integration/oauth_availability_test.rb index 3f1adb9392..c1de717cc6 100644 --- a/test/integration/oauth_availability_test.rb +++ b/test/integration/oauth_availability_test.rb @@ -46,6 +46,16 @@ class OauthAvailabilityTest < ActionDispatch::IntegrationTest assert_response :not_found end + test "naming a pilot client does not open registration on a dark server" do + with_oauth_availability acceptance: false, issuance: false, pilot_client_ids: [ oauth_clients(:mcp_client).client_id ] do + assert_no_difference "Oauth::Client.count" do + untenanted { post oauth_clients_path, params: { client_id: oauth_clients(:mcp_client).client_id, redirect_uris: [ "http://127.0.0.1:9999/callback" ] }, as: :json } + end + end + + assert_response :not_found + end + test "pausing issuance refuses registration with 503" do with_oauth_availability acceptance: true, issuance: false do assert_no_difference "Oauth::Client.count" do From 3e6cc4a21d968ffd537038ba20e7bdaaa50391af Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 5 Oct 2026 21:13:16 -0700 Subject: [PATCH 10/26] Reject non-string token request parameters as malformed An array or object in place of code or code_verifier reached AuthorizationCode.parse and the PKCE digest and raised, answering 500. Require each grant parameter to be a single non-blank string and answer anything else with invalid_request, before any lookup. --- app/controllers/oauth/tokens_controller.rb | 10 ++++++++-- test/integration/oauth_flow_test.rb | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/app/controllers/oauth/tokens_controller.rb b/app/controllers/oauth/tokens_controller.rb index 13b0eaeae9..18dd522823 100644 --- a/app/controllers/oauth/tokens_controller.rb +++ b/app/controllers/oauth/tokens_controller.rb @@ -41,12 +41,18 @@ 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. + # 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 - if (missing = %w[ code code_verifier redirect_uri client_id ].select { |name| params[name].blank? }).any? - oauth_error "invalid_request", "Missing required parameter: #{missing.to_sentence}" + if (missing = %w[ code code_verifier redirect_uri client_id ].reject { |name| string_param?(name) }).any? + oauth_error "invalid_request", "Missing or malformed parameter: #{missing.to_sentence}" end end + def string_param?(name) + params[name].is_a?(String) && params[name].present? + end + def set_auth_code unless @auth_code = Oauth::AuthorizationCode.parse(params[:code]) oauth_error "invalid_grant", "Invalid or expired authorization code" diff --git a/test/integration/oauth_flow_test.rb b/test/integration/oauth_flow_test.rb index 57a56278a0..89f6b0a49a 100644 --- a/test/integration/oauth_flow_test.rb +++ b/test/integration/oauth_flow_test.rb @@ -451,6 +451,27 @@ class OauthFlowTest < ActionDispatch::IntegrationTest end end + test "token exchange rejects non-string parameters as a malformed request" do + code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + client = oauth_clients(:mcp_client) + complete = { grant_type: "authorization_code", code_verifier: code_verifier, + redirect_uri: "http://127.0.0.1:8888/callback", client_id: client.client_id } + + %i[ code code_verifier redirect_uri client_id ].each do |name| + request = complete.merge(code: authorization_code_for(client, code_verifier: code_verifier)) + + [ [ request[name] ], { "value" => request[name] }, 1 ].each do |malformed| + assert_no_difference "Identity::AccessToken.count" do + untenanted { post oauth_token_path, params: request.merge(name => malformed), as: :json } + end + + assert_response :bad_request + assert_equal "invalid_request", response.parsed_body["error"], "#{name} as #{malformed.class}" + assert_match name.to_s, response.parsed_body["error_description"] + end + end + end + test "token exchange rejects a code issued to another client" do code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" code = authorization_code_for(oauth_clients(:mcp_client), code_verifier: code_verifier) From 86d57445256460a1956e5ec66bf1540dcfa9a541 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 5 Oct 2026 21:13:50 -0700 Subject: [PATCH 11/26] Vary the port only for http loopback redirects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 8252 §7.3's port exception is for http loopback listeners. An operator-provisioned https://127.0.0.1:8443 redirect would otherwise also admit any other local port, sending the code to an unregistered local service. https and native schemes on loopback now match exactly. --- app/models/oauth/client.rb | 5 +++-- test/models/oauth/client_test.rb | 6 ++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/app/models/oauth/client.rb b/app/models/oauth/client.rb index 7531ae90a9..dabf21c2ed 100644 --- a/app/models/oauth/client.rb +++ b/app/models/oauth/client.rb @@ -57,7 +57,8 @@ def valid_loopback_uri?(parsed) parsed.scheme == "http" && parsed.host.in?(Oauth::LOOPBACK_HOSTS) end - # Only the port may vary (RFC 8252 §7.3). The host must match too: + # Only the port may vary, and only for an http loopback redirect (RFC 8252 + # §7.3): https or a native scheme on loopback stays exact. The host must match too: # 127.0.0.1, localhost and ::1 are not interchangeable, and localhost # may not even resolve to loopback (RFC 8252 §8.3). def matching_loopback?(uri) @@ -66,7 +67,7 @@ def matching_loopback?(uri) redirect_uris.any? do |redirect_uri| redirect = URI.parse(redirect_uri) - redirect.scheme == parsed.scheme && + redirect.scheme == "http" && parsed.scheme == "http" && redirect.host.in?(Oauth::LOOPBACK_HOSTS) && redirect.host.casecmp?(parsed.host.to_s) && redirect.path == parsed.path && diff --git a/test/models/oauth/client_test.rb b/test/models/oauth/client_test.rb index cea59aac08..1a1e4d77c0 100644 --- a/test/models/oauth/client_test.rb +++ b/test/models/oauth/client_test.rb @@ -127,6 +127,12 @@ class Oauth::ClientTest < ActiveSupport::TestCase assert client.errors[:name].any? end + test "allows_redirect? varies the port only for http loopback redirects" do + client = Oauth::Client.new(redirect_uris: %w[ https://127.0.0.1:8443/callback ]) + assert client.allows_redirect?("https://127.0.0.1:8443/callback") + assert_not client.allows_redirect?("https://127.0.0.1:9443/callback") + end + test "allows_redirect? requires matching path for loopback flexibility" do client = Oauth::Client.new(redirect_uris: %w[ http://127.0.0.1:8888/callback ]) assert_not client.allows_redirect?("http://127.0.0.1:9999/other") From ca1933220ad826da66aad44660dae4c24a019ba0 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 5 Oct 2026 21:39:52 -0700 Subject: [PATCH 12/26] Point a client's access_tokens at oauth_client_id, and drop the stray foreign key Oauth::Client#access_tokens inferred client_id as its foreign key, a column identity_access_tokens doesn't have, so any use of the association raised. Nothing used it yet; the cleanup of abandoned registrations will. db/schema.rb also declared a foreign key from identity_access_tokens to oauth_clients that the AddOauth migration explicitly doesn't create (foreign_key: false), and db/schema_sqlite.rb doesn't have. A migrated database had none, while a schema-loaded MySQL one (CI, fresh installs) did. --- app/models/oauth/client.rb | 2 +- db/schema.rb | 2 -- test/models/oauth/client_test.rb | 7 +++++++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/app/models/oauth/client.rb b/app/models/oauth/client.rb index dabf21c2ed..c5dce738b7 100644 --- a/app/models/oauth/client.rb +++ b/app/models/oauth/client.rb @@ -1,5 +1,5 @@ class Oauth::Client < ApplicationRecord - has_many :access_tokens, class_name: "Identity::AccessToken" + has_many :access_tokens, class_name: "Identity::AccessToken", foreign_key: :oauth_client_id, inverse_of: :oauth_client has_secure_token :client_id, length: 32 diff --git a/db/schema.rb b/db/schema.rb index 5747aa5db5..d5f52d7830 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -882,6 +882,4 @@ t.index ["account_id"], name: "index_webhooks_on_account_id" t.index ["board_id", "subscribed_actions"], name: "index_webhooks_on_board_id_and_subscribed_actions", length: { subscribed_actions: 255 } end - - add_foreign_key "identity_access_tokens", "oauth_clients" end diff --git a/test/models/oauth/client_test.rb b/test/models/oauth/client_test.rb index 1a1e4d77c0..c55549af1e 100644 --- a/test/models/oauth/client_test.rb +++ b/test/models/oauth/client_test.rb @@ -162,4 +162,11 @@ class Oauth::ClientTest < ActiveSupport::TestCase dcr_clients = Oauth::Client.dynamically_registered assert dcr_clients.all?(&:dynamically_registered?) end + + test "access_tokens are the tokens issued to the client" do + client = oauth_clients(:mcp_client) + token = identities(:david).access_tokens.create!(oauth_client: client) + + assert_equal [ token ], client.access_tokens.to_a + end end From 0c916591da0642456bd48c3c929a4d93703fc909 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 6 Oct 2026 04:22:47 -0700 Subject: [PATCH 13/26] Refuse personal access token management to OAuth grants An app holding a write grant could call POST /my/access_tokens.json and mint a personal access token. That token isn't listed under Connected Apps, survives disconnecting the app, and keeps working while OAuth is dark. Bearer authentication now records which token authenticated the request (Current.access_token). The personal access token endpoints answer 403 to an OAuth grant. Personal access tokens can still manage personal access tokens, as fizzy-cli does. --- app/controllers/concerns/authentication.rb | 5 +++-- .../my/access_tokens_controller.rb | 9 ++++++++ app/models/current.rb | 2 +- app/models/identity.rb | 6 ----- app/models/identity/access_token.rb | 6 +++++ .../my/access_tokens_controller_test.rb | 22 +++++++++++++++++++ 6 files changed, 41 insertions(+), 9 deletions(-) diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 29fdab4ef7..a4011e8aef 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -59,8 +59,9 @@ def authenticate_by_bearer_token if request.authorization.to_s.include?("Bearer") if bearer_token_authenticatable_request? authenticate_or_request_with_http_token do |token| - if identity = Identity.find_by_permissable_access_token(token, method: request.method) - Current.identity = identity + if access_token = Identity::AccessToken.find_permissable(token, method: request.method) + Current.access_token = access_token + Current.identity = access_token.identity end end else diff --git a/app/controllers/my/access_tokens_controller.rb b/app/controllers/my/access_tokens_controller.rb index 1d52e8cd5e..0446457824 100644 --- a/app/controllers/my/access_tokens_controller.rb +++ b/app/controllers/my/access_tokens_controller.rb @@ -3,6 +3,11 @@ class My::AccessTokensController < ApplicationController skip_before_action :require_account + # A personal access token outlives the app that minted it: it isn't listed + # under Connected Apps and survives a disconnect. So an OAuth grant may not + # manage personal access tokens at all. + before_action :forbid_oauth_grants + def index @access_tokens = my_access_tokens.order(created_at: :desc) end @@ -44,6 +49,10 @@ def destroy end private + def forbid_oauth_grants + head :forbidden if Current.access_token&.oauth_client_id? + end + def my_access_tokens Current.identity.access_tokens.personal end diff --git a/app/models/current.rb b/app/models/current.rb index 94ef9688c0..850879174f 100644 --- a/app/models/current.rb +++ b/app/models/current.rb @@ -1,5 +1,5 @@ class Current < ActiveSupport::CurrentAttributes - attribute :session, :user, :identity, :account + attribute :session, :user, :identity, :account, :access_token attribute :http_method, :request_id, :user_agent, :ip_address, :referrer def session=(value) diff --git a/app/models/identity.rb b/app/models/identity.rb index 3ddfb79142..aa7977a1c1 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -16,12 +16,6 @@ class Identity < ApplicationRecord validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP } normalizes :email_address, with: ->(value) { value.strip.downcase.presence } - def self.find_by_permissable_access_token(token, method:) - if (access_token = AccessToken.find_by(token: token)) && access_token.honored? && access_token.allows?(method) - access_token.identity - end - end - def send_magic_link(**attributes) attributes[:purpose] = attributes.delete(:for) if attributes.key?(:for) diff --git a/app/models/identity/access_token.rb b/app/models/identity/access_token.rb index a00687868d..a330887bfb 100644 --- a/app/models/identity/access_token.rb +++ b/app/models/identity/access_token.rb @@ -9,6 +9,12 @@ class Identity::AccessToken < ApplicationRecord enum :permission, %w[ read write ].index_by(&:itself), default: :read class << self + def find_permissable(token, method:) + if (access_token = find_by(token: token)) && access_token.honored? && access_token.allows?(method) + access_token + end + end + # An authorization code redeems at most once (RFC 6749 §4.1.2): the grant it # mints is stamped with the code's jti, and the unique index refuses a second # stamp. A replay also revokes the grant the first redemption issued. diff --git a/test/controllers/my/access_tokens_controller_test.rb b/test/controllers/my/access_tokens_controller_test.rb index 480af25f73..9bec86fad0 100644 --- a/test/controllers/my/access_tokens_controller_test.rb +++ b/test/controllers/my/access_tokens_controller_test.rb @@ -59,6 +59,28 @@ class My::AccessTokensControllerTest < ActionDispatch::IntegrationTest assert_equal "read", body["permission"] end + # A personal access token outlives the app that minted it: it isn't listed + # under Connected Apps and survives a disconnect. So an OAuth grant may not + # manage personal access tokens at all. + test "an OAuth grant can't create, list or revoke personal access tokens" do + sign_out + grant = identities(:david).access_tokens.create!(oauth_client: oauth_clients(:mcp_client), permission: :write) + bearer_token = { "HTTP_AUTHORIZATION" => "Bearer #{grant.token}" } + + assert_no_difference -> { identities(:david).access_tokens.count } do + post my_access_tokens_path, params: { access_token: { description: "Escape hatch", permission: "write" } }, env: bearer_token, as: :json + end + assert_response :forbidden + + get my_access_tokens_path, env: bearer_token, as: :json + assert_response :forbidden + + assert_no_difference -> { identities(:david).access_tokens.count } do + delete my_access_token_path(identity_access_tokens(:davids_api_token)), env: bearer_token, as: :json + end + assert_response :forbidden + end + test "cannot create new token via JSON with read-only bearer token" do sign_out bearer_token = { "HTTP_AUTHORIZATION" => "Bearer #{identity_access_tokens(:jasons_api_token).token}" } From 8d7750d71f860189c684cbd5b025b5939504d243 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 6 Oct 2026 04:23:33 -0700 Subject: [PATCH 14/26] Refuse a PKCE verifier outside 43 to 128 unreserved characters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 7636 §4.1 defines code_verifier as 43 to 128 characters from [A-Za-z0-9._~-]. Any non-blank string used to be hashed and compared, so a client sending a short verifier weakened the protection PKCE gives an intercepted code. A malformed verifier now fails like a wrong one. This matches bc3's Oauth::Pkce::VERIFIER_PATTERN. --- app/models/oauth/authorization_code.rb | 4 +++- test/models/oauth/authorization_code_test.rb | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/app/models/oauth/authorization_code.rb b/app/models/oauth/authorization_code.rb index eff12d7ee3..5cb6ca4113 100644 --- a/app/models/oauth/authorization_code.rb +++ b/app/models/oauth/authorization_code.rb @@ -1,6 +1,8 @@ # Codes are stateless, so single use is enforced where they are redeemed: each # carries a jti that the grant it mints is stamped with (Identity::AccessToken.redeem). module Oauth::AuthorizationCode + # RFC 7636 §4.1: 43 to 128 unreserved characters. + VERIFIER = /\A[A-Za-z0-9._~-]{43,128}\z/ Details = ::Data.define(:client_id, :identity_id, :code_challenge, :redirect_uri, :scope, :jti) class << self @@ -24,7 +26,7 @@ def parse(code) end def valid_pkce?(code_data, code_verifier) - code_data && code_verifier.present? && + code_data && code_verifier.is_a?(String) && code_verifier.match?(VERIFIER) && ActiveSupport::SecurityUtils.secure_compare(pkce_challenge(code_verifier), code_data.code_challenge) end diff --git a/test/models/oauth/authorization_code_test.rb b/test/models/oauth/authorization_code_test.rb index 3c0746b7a8..10b6e5141d 100644 --- a/test/models/oauth/authorization_code_test.rb +++ b/test/models/oauth/authorization_code_test.rb @@ -119,6 +119,22 @@ class Oauth::AuthorizationCodeTest < ActiveSupport::TestCase assert Oauth::AuthorizationCode.valid_pkce?(details, code_verifier) end + test "valid_pkce? refuses a verifier outside RFC 7636's 43 to 128 unreserved characters, even one that matches" do + [ "short", "a" * 42, "a" * 129, "#{"a" * 42}!", "#{"a" * 42}é" ].each do |code_verifier| + details = Oauth::AuthorizationCode::Details.new client_id: "test", identity_id: 1, redirect_uri: "http://127.0.0.1/cb", scope: "read", jti: "test-jti", + code_challenge: Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + + assert_not Oauth::AuthorizationCode.valid_pkce?(details, code_verifier), code_verifier + end + + [ "a" * 43, "A1._~-" * 21 + "ab" ].each do |code_verifier| + details = Oauth::AuthorizationCode::Details.new client_id: "test", identity_id: 1, redirect_uri: "http://127.0.0.1/cb", scope: "read", jti: "test-jti", + code_challenge: Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + + assert Oauth::AuthorizationCode.valid_pkce?(details, code_verifier), code_verifier + end + end + test "valid_pkce? returns false for wrong verifier" do code_verifier = "correct_verifier" code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) From 3d75b991a777eb6b5fdba6426c48d6a838345095 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 6 Oct 2026 04:47:05 -0700 Subject: [PATCH 15/26] Keep OAuth grants out of every credential and consent surface The personal-access-token guard closed one door. Codex found two more: a write-scoped grant could consent to another app as a JSON POST and redeem the code, and it could disconnect another app. Passkey registration and transfer links are the same class: any of them gives an app a credential that outlives disconnecting it, or removes another app's. Rather than guard each door as it is found, Authentication gains disallow_oauth_grants (403 when an OAuth grant authenticated the request). It's declared on every such controller: personal access tokens, Connected Apps, passkeys, transfer links and OAuth consent. The passkey challenge stays public, since it authenticates nobody. --- app/controllers/concerns/authentication.rb | 12 +++++ .../my/access_tokens_controller.rb | 10 +--- .../my/connected_apps_controller.rb | 2 + app/controllers/my/passkeys_controller.rb | 2 + .../oauth/authorizations_controller.rb | 1 + app/controllers/transfer_tokens_controller.rb | 2 + test/integration/oauth_grant_reach_test.rb | 49 +++++++++++++++++++ 7 files changed, 69 insertions(+), 9 deletions(-) create mode 100644 test/integration/oauth_grant_reach_test.rb diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index a4011e8aef..d6eea0cac4 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -24,6 +24,14 @@ def allow_unauthenticated_access(**options) allow_unauthorized_access **options end + # An OAuth grant acts for an app on the user's data. It may not manage the + # user's credentials or consent to apps: anything it minted there would + # outlive disconnecting the app, and anything it removed would be another + # app's. Declared on every controller that does either. + def disallow_oauth_grants(**options) + before_action :forbid_oauth_grant, **options + end + def disallow_account_scope(**options) skip_before_action :require_account, **options before_action :redirect_tenanted_request, **options @@ -35,6 +43,10 @@ def authenticated? Current.identity.present? end + def forbid_oauth_grant + head :forbidden if Current.access_token&.oauth_client_id? + end + def require_account unless Current.account.present? redirect_to main_app.session_menu_path(script_name: nil) diff --git a/app/controllers/my/access_tokens_controller.rb b/app/controllers/my/access_tokens_controller.rb index 0446457824..4e836141b3 100644 --- a/app/controllers/my/access_tokens_controller.rb +++ b/app/controllers/my/access_tokens_controller.rb @@ -2,11 +2,7 @@ class My::AccessTokensController < ApplicationController wrap_parameters :access_token, include: %i[ description permission ] skip_before_action :require_account - - # A personal access token outlives the app that minted it: it isn't listed - # under Connected Apps and survives a disconnect. So an OAuth grant may not - # manage personal access tokens at all. - before_action :forbid_oauth_grants + disallow_oauth_grants def index @access_tokens = my_access_tokens.order(created_at: :desc) @@ -49,10 +45,6 @@ def destroy end private - def forbid_oauth_grants - head :forbidden if Current.access_token&.oauth_client_id? - end - def my_access_tokens Current.identity.access_tokens.personal end diff --git a/app/controllers/my/connected_apps_controller.rb b/app/controllers/my/connected_apps_controller.rb index 322451db7e..8c4f8cdf43 100644 --- a/app/controllers/my/connected_apps_controller.rb +++ b/app/controllers/my/connected_apps_controller.rb @@ -1,4 +1,6 @@ class My::ConnectedAppsController < ApplicationController + disallow_oauth_grants + before_action :set_connected_apps, only: :index before_action :set_oauth_client, only: :destroy diff --git a/app/controllers/my/passkeys_controller.rb b/app/controllers/my/passkeys_controller.rb index 45fb5e300a..b2c86ff23f 100644 --- a/app/controllers/my/passkeys_controller.rb +++ b/app/controllers/my/passkeys_controller.rb @@ -1,4 +1,6 @@ class My::PasskeysController < ApplicationController + disallow_oauth_grants + include ActionPack::Passkey::Request before_action :set_passkey, only: %i[ edit update destroy ] diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb index ac5cf461af..679608b3ef 100644 --- a/app/controllers/oauth/authorizations_controller.rb +++ b/app/controllers/oauth/authorizations_controller.rb @@ -14,6 +14,7 @@ class Oauth::AuthorizationsController < Oauth::BaseController before_action :require_issuance_enabled, unless: :denial? before_action :save_oauth_return_url before_action :require_authentication + disallow_oauth_grants before_action :set_client before_action :validate_redirect_uri diff --git a/app/controllers/transfer_tokens_controller.rb b/app/controllers/transfer_tokens_controller.rb index 0040799e3d..416a514c6d 100644 --- a/app/controllers/transfer_tokens_controller.rb +++ b/app/controllers/transfer_tokens_controller.rb @@ -1,4 +1,6 @@ class TransferTokensController < ApplicationController + disallow_oauth_grants + def create Current.identity.regenerate_transfer_token redirect_to user_path(Current.user) diff --git a/test/integration/oauth_grant_reach_test.rb b/test/integration/oauth_grant_reach_test.rb new file mode 100644 index 0000000000..5977c0061a --- /dev/null +++ b/test/integration/oauth_grant_reach_test.rb @@ -0,0 +1,49 @@ +require "test_helper" + +# An OAuth grant acts for an app on the user's data. It may not manage the +# user's credentials or consent to apps: anything it minted there would outlive +# disconnecting the app, and anything it removed would be someone else's. +class OauthGrantReachTest < ActionDispatch::IntegrationTest + setup do + @grant = identities(:david).access_tokens.create!(oauth_client: oauth_clients(:mcp_client), permission: :write) + @bearer = { "HTTP_AUTHORIZATION" => "Bearer #{@grant.token}" } + end + + test "an OAuth grant can't consent to an app" do + untenanted do + post oauth_authorization_path, params: { client_id: oauth_clients(:trusted_client).client_id, redirect_uri: oauth_clients(:trusted_client).redirect_uris.first, + response_type: "code", code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", code_challenge_method: "S256", scope: "read write", state: "s" }, + env: @bearer, as: :json + end + + assert_response :forbidden + end + + test "an OAuth grant can't disconnect an app" do + other = identities(:david).access_tokens.create!(oauth_client: oauth_clients(:trusted_client)) + + delete my_connected_app_path(oauth_clients(:trusted_client)), env: @bearer, as: :json + + assert_response :forbidden + assert Identity::AccessToken.exists?(other.id) + end + + test "an OAuth grant can't register a passkey" do + post my_passkeys_path, params: { passkey: { client_data_json: "{}" } }, env: @bearer, as: :json + assert_response :forbidden + end + + test "an OAuth grant can't mint a transfer link" do + post transfer_token_path, env: @bearer, as: :json + + assert_response :forbidden + end + + test "a personal access token still manages personal access tokens" do + pat = { "HTTP_AUTHORIZATION" => "Bearer #{identity_access_tokens(:davids_api_token).token}" } + + get my_access_tokens_path, env: pat, as: :json + + assert_response :success + end +end From 6971938850c2f9b22303e1535da2016ad498c82b Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 6 Oct 2026 04:55:55 -0700 Subject: [PATCH 16/26] Keep OAuth grants out of email changes too A write grant could ask to change its user's email address to one the app controls. Confirming through the emailed link then moved the user to an identity the app owns, and that outlived disconnecting it. The email change controller now declares disallow_oauth_grants, as every other credential surface does. A sweep of controllers that touch identity, session or credential state found no others. --- app/controllers/concerns/authentication.rb | 4 +++- app/controllers/users/email_addresses_controller.rb | 2 ++ test/integration/oauth_grant_reach_test.rb | 8 ++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index d6eea0cac4..3e85a6a1bd 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -27,7 +27,9 @@ def allow_unauthenticated_access(**options) # An OAuth grant acts for an app on the user's data. It may not manage the # user's credentials or consent to apps: anything it minted there would # outlive disconnecting the app, and anything it removed would be another - # app's. Declared on every controller that does either. + # app's. Declared on every controller that does either: personal access + # tokens, Connected Apps, passkeys, transfer links, email changes and OAuth + # consent. def disallow_oauth_grants(**options) before_action :forbid_oauth_grant, **options end diff --git a/app/controllers/users/email_addresses_controller.rb b/app/controllers/users/email_addresses_controller.rb index 58d8a2723e..3003589845 100644 --- a/app/controllers/users/email_addresses_controller.rb +++ b/app/controllers/users/email_addresses_controller.rb @@ -1,4 +1,6 @@ class Users::EmailAddressesController < ApplicationController + disallow_oauth_grants + before_action :set_user before_action :ensure_valid_email_address, only: :create rate_limit to: 5, within: 1.hour, only: :create diff --git a/test/integration/oauth_grant_reach_test.rb b/test/integration/oauth_grant_reach_test.rb index 5977c0061a..4d4df24d62 100644 --- a/test/integration/oauth_grant_reach_test.rb +++ b/test/integration/oauth_grant_reach_test.rb @@ -39,6 +39,14 @@ class OauthGrantReachTest < ActionDispatch::IntegrationTest assert_response :forbidden end + test "an OAuth grant can't change the user's email address" do + assert_no_emails do + post user_email_addresses_path(users(:david)), params: { email_address: "app-controlled@example.com" }, env: @bearer, as: :json + end + + assert_response :forbidden + end + test "a personal access token still manages personal access tokens" do pat = { "HTTP_AUTHORIZATION" => "Bearer #{identity_access_tokens(:davids_api_token).token}" } From 910e88ade9025aa9d1e333af66aa6bb2db24eec8 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 6 Oct 2026 10:13:13 -0700 Subject: [PATCH 17/26] Keep credentials that outlive a grant away from OAuth apps An OAuth grant could read the account join code and every webhook's signing secret and payload URL. A join code lets its holder add an identity to the account; a signing secret forges deliveries; a Slack or Campfire payload URL posts into that room. Each keeps working after the user disconnects the app, which is the line disallow_oauth_grants already draws for credential management. - Join code: OAuth grants are forbidden on every action. Personal access tokens and sessions keep reading it. - Account exports: forbidden to grants too, since the export carries the join code and every webhook's credentials. - Webhooks: grants see signing_secret and payload_url only in the response to the create that made the webhook, as GitHub and Stripe do. Index, show, update and activation omit them. The fields render outside the fragment cache because the answer depends on the caller. --- app/controllers/account/exports_controller.rb | 2 + .../account/join_codes_controller.rb | 2 + app/controllers/concerns/authentication.rb | 13 ++- app/controllers/webhooks_controller.rb | 5 +- app/views/webhooks/_webhook.json.jbuilder | 14 +++- docs/api/sections/account.md | 2 + docs/api/sections/exports.md | 2 + docs/api/sections/webhooks.md | 2 + test/integration/oauth_grant_reach_test.rb | 81 +++++++++++++++++++ 9 files changed, 117 insertions(+), 6 deletions(-) diff --git a/app/controllers/account/exports_controller.rb b/app/controllers/account/exports_controller.rb index 32fb5bd42a..640004bd51 100644 --- a/app/controllers/account/exports_controller.rb +++ b/app/controllers/account/exports_controller.rb @@ -1,4 +1,6 @@ class Account::ExportsController < ApplicationController + disallow_oauth_grants + before_action :ensure_admin_or_owner before_action :ensure_export_limit_not_exceeded, only: :create before_action :set_export, only: :show diff --git a/app/controllers/account/join_codes_controller.rb b/app/controllers/account/join_codes_controller.rb index e7306308bd..c46761b1d0 100644 --- a/app/controllers/account/join_codes_controller.rb +++ b/app/controllers/account/join_codes_controller.rb @@ -1,6 +1,8 @@ class Account::JoinCodesController < ApplicationController wrap_parameters :account_join_code, include: %i[ usage_limit ] + disallow_oauth_grants + before_action :set_join_code before_action :ensure_admin, only: %i[ update destroy ] diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 3e85a6a1bd..dd937387c6 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -4,7 +4,7 @@ module Authentication included do before_action :require_account # Checking and setting account must happen first before_action :require_authentication - helper_method :authenticated? + helper_method :authenticated?, :oauth_grant? helper_method :email_address_pending_authentication etag { Current.identity.id if authenticated? } @@ -29,7 +29,10 @@ def allow_unauthenticated_access(**options) # outlive disconnecting the app, and anything it removed would be another # app's. Declared on every controller that does either: personal access # tokens, Connected Apps, passkeys, transfer links, email changes and OAuth - # consent. + # consent. For the same reason it may not read a credential that would + # outlive the grant: the account join code, and account exports, which carry + # that code and every webhook's credentials. Webhooks themselves stay + # readable but withhold their credentials (see webhooks/_webhook.json). def disallow_oauth_grants(**options) before_action :forbid_oauth_grant, **options end @@ -45,8 +48,12 @@ def authenticated? Current.identity.present? end + def oauth_grant? + Current.access_token&.oauth_client_id? + end + def forbid_oauth_grant - head :forbidden if Current.access_token&.oauth_client_id? + head :forbidden if oauth_grant? end def require_account diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index ab29badaab..071aedb888 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -23,7 +23,10 @@ def create if @webhook.save respond_to do |format| format.html { redirect_to @webhook } - format.json { render :show, status: :created, location: board_webhook_url(@webhook.board, @webhook, format: :json) } + format.json do + render partial: "webhooks/webhook", locals: { webhook: @webhook, reveal_credentials: true }, + status: :created, location: board_webhook_url(@webhook.board, @webhook, format: :json) + end end else respond_to do |format| diff --git a/app/views/webhooks/_webhook.json.jbuilder b/app/views/webhooks/_webhook.json.jbuilder index a55257984e..87f2e064ef 100644 --- a/app/views/webhooks/_webhook.json.jbuilder +++ b/app/views/webhooks/_webhook.json.jbuilder @@ -1,8 +1,18 @@ json.cache! [ webhook, webhook.board ] do - json.(webhook, :id, :name, :active, :signing_secret, :subscribed_actions) - json.payload_url webhook.url + json.(webhook, :id, :name, :active, :subscribed_actions) json.created_at webhook.created_at.utc json.url board_webhook_url(webhook.board, webhook) json.board webhook.board, partial: "boards/board", as: :board end + +# A webhook's signing secret lets its holder forge deliveries, and its URL is +# often itself a credential (Slack, Campfire). An OAuth grant gets both once, +# when it creates the webhook, the way GitHub and Stripe hand out webhook +# secrets. After that they stay with people and personal access tokens, so an +# app keeps no credential it didn't make once it's disconnected. Kept outside +# the cache because the answer depends on the caller. +if local_assigns[:reveal_credentials] || !oauth_grant? + json.signing_secret webhook.signing_secret + json.payload_url webhook.url +end diff --git a/docs/api/sections/account.md b/docs/api/sections/account.md index 7984aeefbe..735edbc711 100644 --- a/docs/api/sections/account.md +++ b/docs/api/sections/account.md @@ -36,6 +36,8 @@ __Response:__ A join code is `active` when `usage_count` is less than `usage_limit`. +Anyone with the join code can add themselves to the account, so apps connected with OAuth can't read, change or reset it. These endpoints respond `403 Forbidden` to them. + ## `PUT /:account_slug/account/join_code` Updates the join code's usage limit. Requires admin role. diff --git a/docs/api/sections/exports.md b/docs/api/sections/exports.md index 10ea38b102..9a4948329c 100644 --- a/docs/api/sections/exports.md +++ b/docs/api/sections/exports.md @@ -10,6 +10,8 @@ Possible export statuses are: Completed export files expire after 24 hours. When that happens, request a new export. +An account export includes the account's join code and webhook credentials, so apps connected with OAuth can't create or read account exports. Those endpoints respond `403 Forbidden` to them. + ## `POST /:account_slug/account/exports` Starts an account export for the current account. Only account admins and owners can create account exports. diff --git a/docs/api/sections/webhooks.md b/docs/api/sections/webhooks.md index a4f48b5b14..7ad53b7ee7 100644 --- a/docs/api/sections/webhooks.md +++ b/docs/api/sections/webhooks.md @@ -2,6 +2,8 @@ Webhooks notify another application when something happens on a board. Only account admins can list, view, create, update, delete, or reactivate webhooks. +`signing_secret` and `payload_url` are credentials. Apps connected with OAuth receive them only in the response to the `POST` that creates the webhook; every other webhook response omits both for them. Store the signing secret when you create the webhook. + ## `GET /:account_slug/boards/:board_id/webhooks` Returns a paginated list of webhooks for a board. diff --git a/test/integration/oauth_grant_reach_test.rb b/test/integration/oauth_grant_reach_test.rb index 4d4df24d62..bf60286619 100644 --- a/test/integration/oauth_grant_reach_test.rb +++ b/test/integration/oauth_grant_reach_test.rb @@ -47,6 +47,76 @@ class OauthGrantReachTest < ActionDispatch::IntegrationTest assert_response :forbidden end + test "an OAuth grant can't read or reset the account's join code" do + code = accounts("37s").join_code.code + + get account_join_code_path, env: @bearer, as: :json + assert_response :forbidden + + delete account_join_code_path, env: admin_grant, as: :json + assert_response :forbidden + assert_equal code, accounts("37s").join_code.reload.code + end + + test "an OAuth grant can't export the account, which carries its join code and webhook credentials" do + assert_no_difference -> { Account::Export.count } do + post account_exports_path, env: admin_grant, as: :json + end + assert_response :forbidden + + export = Account::Export.create!(account: accounts("37s"), user: users(:kevin)) + get account_export_path(export), env: admin_grant, as: :json + assert_response :forbidden + end + + test "an OAuth grant reads webhooks without their credentials" do + webhook = webhooks(:active) + + get board_webhooks_path(webhook.board), env: admin_grant, as: :json + assert_response :success + assert_equal webhook.id, @response.parsed_body.first["id"] + assert_webhook_credentials_withheld @response.parsed_body.first + + get board_webhook_path(webhook.board, webhook), env: admin_grant, as: :json + assert_response :success + assert_webhook_credentials_withheld @response.parsed_body + + put board_webhook_path(webhook.board, webhook), params: { webhook: { name: "Renamed" } }, env: admin_grant, as: :json + assert_response :success + assert_equal "Renamed", @response.parsed_body["name"] + assert_webhook_credentials_withheld @response.parsed_body + + post board_webhook_activation_path(webhook.board, webhook), env: admin_grant, as: :json + assert_response :created + assert_webhook_credentials_withheld @response.parsed_body + end + + test "an OAuth grant gets a webhook's credentials once, when it creates it" do + post board_webhooks_path(boards(:writebook)), params: { webhook: { name: "App", url: "https://app.example.com/hooks" } }, env: admin_grant, as: :json + + assert_response :created + webhook = Webhook.find(@response.parsed_body["id"]) + assert_equal webhook.signing_secret, @response.parsed_body["signing_secret"] + assert_equal "https://app.example.com/hooks", @response.parsed_body["payload_url"] + + get board_webhook_path(webhook.board, webhook), env: admin_grant, as: :json + assert_webhook_credentials_withheld @response.parsed_body + end + + test "a personal access token still reads the join code and webhook credentials" do + webhook = webhooks(:active) + pat = { "HTTP_AUTHORIZATION" => "Bearer #{identities(:kevin).access_tokens.create!(permission: :read).token}" } + + get account_join_code_path, env: pat, as: :json + assert_response :success + assert_equal accounts("37s").join_code.code, @response.parsed_body["code"] + + get board_webhook_path(webhook.board, webhook), env: pat, as: :json + assert_response :success + assert_equal webhook.signing_secret, @response.parsed_body["signing_secret"] + assert_equal webhook.url, @response.parsed_body["payload_url"] + end + test "a personal access token still manages personal access tokens" do pat = { "HTTP_AUTHORIZATION" => "Bearer #{identity_access_tokens(:davids_api_token).token}" } @@ -54,4 +124,15 @@ class OauthGrantReachTest < ActionDispatch::IntegrationTest assert_response :success end + + private + def admin_grant + token = identities(:kevin).access_tokens.create!(oauth_client: oauth_clients(:mcp_client), permission: :write).token + { "HTTP_AUTHORIZATION" => "Bearer #{token}" } + end + + def assert_webhook_credentials_withheld(json) + assert_not json.key?("signing_secret"), "expected no signing_secret in #{json.keys}" + assert_not json.key?("payload_url"), "expected no payload_url in #{json.keys}" + end end From f7304f2b15d0c73a8a31d768dd3b443257c1d8cb Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 16 Oct 2026 23:52:33 -0700 Subject: [PATCH 18/26] Filter authorization codes, PKCE verifiers and DPoP proofs from logs The partial matches already cover tokens and secrets, but not a bare code, its verifier, a client assertion or a DPoP proof. code is matched whole, so the public code_challenge stays readable, and the logged redirect to the client loses its code too. --- config/application.rb | 6 ++++++ test/lib/oauth_parameter_filter_test.rb | 24 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 test/lib/oauth_parameter_filter_test.rb diff --git a/config/application.rb b/config/application.rb index b1e92e0248..5e03e38d29 100644 --- a/config/application.rb +++ b/config/application.rb @@ -16,6 +16,12 @@ class Application < Rails::Application passw secret token _key crypt salt certificate otp ssn ] + # OAuth credentials the list above doesn't reach by partial match: an + # authorization code and its PKCE verifier, client assertions and DPoP + # proofs. `code` is matched whole, so code_challenge, which is public, stays + # readable. This also filters the code from the logged redirect to the client. + config.filter_parameters += [ /\Acode\z/i, :code_verifier, :device_code, :client_assertion, :dpop ] + # Include the `lib` directory in autoload paths. Use the `ignore:` option # to list subdirectories that don't contain `.rb` files or that shouldn't # be reloaded or eager loaded. diff --git a/test/lib/oauth_parameter_filter_test.rb b/test/lib/oauth_parameter_filter_test.rb new file mode 100644 index 0000000000..6c17fad2ab --- /dev/null +++ b/test/lib/oauth_parameter_filter_test.rb @@ -0,0 +1,24 @@ +require "test_helper" + +class OauthParameterFilterTest < ActiveSupport::TestCase + CREDENTIALS = %w[ code code_verifier access_token refresh_token subject_token device_code client_secret client_assertion token dpop ] + + test "every OAuth credential parameter is filtered from logs" do + filtered = filter(CREDENTIALS.index_with { "secret-value" }) + + CREDENTIALS.each do |name| + assert_equal "[FILTERED]", filtered[name], "#{name} is logged" + end + end + + test "public authorization parameters stay readable" do + public_parameters = { "code_challenge" => "challenge", "code_challenge_method" => "S256", "state" => "xyz", "client_id" => "abc", "redirect_uri" => "http://127.0.0.1/cb" } + + assert_equal public_parameters, filter(public_parameters) + end + + private + def filter(parameters) + ActiveSupport::ParameterFilter.new(Rails.application.config.filter_parameters).filter(parameters) + end +end From 0a2b7e12095c3223953d97e6e6f6e666cf372065 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 16 Oct 2026 23:52:35 -0700 Subject: [PATCH 19/26] Accept an authorization request without state, and bound one that has it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PKCE is the CSRF defence (OAuth 2.1 §7.5.1), so requiring state turned away clients that follow the spec. A state that is sent comes back exactly, and must be a single string of at most 2048 characters. A request that long no longer fits the cookie-stored session once encrypted, and saving it as the return URL raised CookieOverflow, a 500. It was saved for people already signed in too, since it runs ahead of authentication. Now it's saved only for someone without a session, and only when it fits. --- .../oauth/authorizations_controller.rb | 22 +++- test/integration/oauth_flow_test.rb | 109 ++++++++++++++++++ 2 files changed, 128 insertions(+), 3 deletions(-) diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb index 679608b3ef..b7f63da033 100644 --- a/app/controllers/oauth/authorizations_controller.rb +++ b/app/controllers/oauth/authorizations_controller.rb @@ -1,6 +1,8 @@ class Oauth::AuthorizationsController < Oauth::BaseController FORM_ACTION_ORIGIN = %r{\Ahttps?://[a-z0-9.-]+(:\d+)?\z}i FORM_ACTION_SCHEME = /\A[a-z][a-z0-9+.-]*\z/i + STATE_MAXIMUM_LENGTH = 2048 + RETURN_URL_MAXIMUM_LENGTH = 2000 # Browsers apply form-action to every hop of a form submission, redirects # included, so the app-wide form-action 'self' would block the consent POST's @@ -50,8 +52,14 @@ def denial? request.post? && params[:error] == "access_denied" end + # Runs before authentication, so a person already signed in is told apart + # by their session cookie. The URL goes into the cookie-stored session, + # which overflows (a 500) past 4KB once encrypted, so a request too long to + # keep isn't kept: the person signs in and starts again from the app. def save_oauth_return_url - session[:return_to_after_authenticating] = request.url if request.get? && !authenticated? + if request.get? && !find_session_by_cookie && request.url.bytesize <= RETURN_URL_MAXIMUM_LENGTH + session[:return_to_after_authenticating] = request.url + end end def set_client @@ -87,12 +95,20 @@ def validate_scope end end + # state is optional: PKCE is the CSRF defence (OAuth 2.1 §7.5.1), so a + # client that doesn't send it isn't refused. One that does gets it back + # exactly, and it must be a single string of bounded length, since it is + # carried through the consent form and into the redirect. def validate_state - unless performed? || params[:state].present? - redirect_with_error "invalid_request", "state is required" + unless performed? || valid_state? + redirect_with_error "invalid_request", "state must be a string of at most #{STATE_MAXIMUM_LENGTH} characters" end end + def valid_state? + params[:state].nil? || (params[:state].is_a?(String) && params[:state].length <= STATE_MAXIMUM_LENGTH) + end + def redirect_with_error(error, description) if params[:redirect_uri].present? && @client&.allows_redirect?(params[:redirect_uri]) redirect_to error_redirect_uri(error, description), allow_other_host: true diff --git a/test/integration/oauth_flow_test.rb b/test/integration/oauth_flow_test.rb index 89f6b0a49a..060a62bc88 100644 --- a/test/integration/oauth_flow_test.rb +++ b/test/integration/oauth_flow_test.rb @@ -126,6 +126,115 @@ class OauthFlowTest < ActionDispatch::IntegrationTest end + test "authorization consent issues a code without state, and returns none" do + sign_in_as :david + client = oauth_clients(:mcp_client) + + untenanted do + get new_oauth_authorization_path, params: { + client_id: 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" + } + end + + assert_response :success + + untenanted do + post oauth_authorization_path, params: { + client_id: 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: "" + } + end + + assert_response :redirect + params = CGI.parse(URI.parse(response.location).query) + assert_not_nil params["code"]&.first + assert_not params.key?("state") + end + + test "authorization echoes a state of the maximum length and refuses a longer one" do + sign_in_as :david + client = oauth_clients(:mcp_client) + authorization = { + client_id: 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" + } + + untenanted do + get new_oauth_authorization_path, params: authorization.merge(state: "s" * 2048) + end + + assert_response :success + + untenanted do + post oauth_authorization_path, params: authorization.merge(state: "s" * 2048) + end + + assert_response :redirect + assert_equal "s" * 2048, CGI.parse(URI.parse(response.location).query)["state"].first + + untenanted do + get new_oauth_authorization_path, params: authorization.merge(state: "s" * 2049) + end + + assert_response :redirect + params = CGI.parse(URI.parse(response.location).query) + assert_equal "invalid_request", params["error"].first + assert_not params.key?("code") + end + + test "an authorization request too long to keep in the session still asks the person to sign in" do + client = oauth_clients(:mcp_client) + + untenanted do + get new_oauth_authorization_path, params: { + client_id: client.client_id, + redirect_uri: "http://127.0.0.1:8888/callback", + response_type: "code", + code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + code_challenge_method: "S256", + state: "s" * 2048 + } + end + + assert_response :redirect + assert_match %r{/session/new}, response.location + assert_nil session[:return_to_after_authenticating] + end + + test "authorization refuses a state that isn't a single string" do + sign_in_as :david + client = oauth_clients(:mcp_client) + + untenanted do + get new_oauth_authorization_path, params: { + client_id: 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: [ "a", "b" ] + } + end + + assert_response :redirect + assert_equal "invalid_request", CGI.parse(URI.parse(response.location).query)["error"].first + end + test "authorization preselects Read + Write when the client requests read write" do sign_in_as :david From 5075bac8335c501065d820a02fbd15f0d84de2b1 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 17 Oct 2026 00:26:50 -0700 Subject: [PATCH 20/26] Reach Connected Apps without an account, as personal access tokens are Grants belong to the identity. One whose only membership ended was sent to the session menu and could never revoke them, though joining an account again would restore their reach. --- app/controllers/my/connected_apps_controller.rb | 4 ++++ app/views/my/connected_apps/index.html.erb | 6 +++++- .../my/connected_apps_controller_test.rb | 17 +++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/app/controllers/my/connected_apps_controller.rb b/app/controllers/my/connected_apps_controller.rb index 8c4f8cdf43..94da5900e2 100644 --- a/app/controllers/my/connected_apps_controller.rb +++ b/app/controllers/my/connected_apps_controller.rb @@ -1,4 +1,8 @@ class My::ConnectedAppsController < ApplicationController + # Grants belong to the identity, not to an account, so revoking them must not + # depend on still having one: an identity whose only membership has ended + # keeps its grants, and could regain their reach by joining again. + skip_before_action :require_account disallow_oauth_grants before_action :set_connected_apps, only: :index diff --git a/app/views/my/connected_apps/index.html.erb b/app/views/my/connected_apps/index.html.erb index 75db574ba6..1e39adf810 100644 --- a/app/views/my/connected_apps/index.html.erb +++ b/app/views/my/connected_apps/index.html.erb @@ -2,7 +2,11 @@ <% content_for :header do %>
- <%= back_link_to "My profile", user_path(Current.user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> + <% if Current.user %> + <%= back_link_to "My profile", user_path(Current.user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> + <% else %> + <%= back_link_to "Menu", session_menu_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> + <% end %>

<%= @page_title %>

diff --git a/test/controllers/my/connected_apps_controller_test.rb b/test/controllers/my/connected_apps_controller_test.rb index d67ba5b17c..9eb5eb9dd4 100644 --- a/test/controllers/my/connected_apps_controller_test.rb +++ b/test/controllers/my/connected_apps_controller_test.rb @@ -15,6 +15,23 @@ class My::ConnectedAppsControllerTest < ActionDispatch::IntegrationTest assert_match client.name, response.body end + test "index and destroy work untenanted, so an identity without an account can still disconnect apps" do + client = oauth_clients(:mcp_client) + @identity.access_tokens.create!(oauth_client: client, permission: :read) + + untenanted do + get my_connected_apps_path + + assert_response :success + assert_match client.name, response.body + assert_in_body session_menu_path + + assert_difference -> { @identity.access_tokens.oauth.count }, -1 do + delete my_connected_app_path(client) + end + end + end + test "index excludes PATs" do # PAT has no oauth_client @identity.access_tokens.create!(permission: :read, description: "My PAT") From 76024c1e2f37eaa00dc8028acfadc81d96b2a29b Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 17 Oct 2026 00:28:38 -0700 Subject: [PATCH 21/26] Bound what open registration stores: ten redirect URIs of 2000 characters Registration is unauthenticated, and the rate limit only counts requests, so each one could store an arbitrarily large list. The list is now checked before any URI is parsed, and the model holds the same bound for every way a client is created. --- app/controllers/oauth/clients_controller.rb | 16 ++++++++++++++-- app/models/oauth/client.rb | 13 +++++++++++-- test/integration/oauth_flow_test.rb | 17 +++++++++++++++++ test/models/oauth/client_test.rb | 6 ++++++ 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/app/controllers/oauth/clients_controller.rb b/app/controllers/oauth/clients_controller.rb index a897f3ad21..d36296904a 100644 --- a/app/controllers/oauth/clients_controller.rb +++ b/app/controllers/oauth/clients_controller.rb @@ -28,12 +28,24 @@ def piloting_client_id nil end + # Bounded before any URI is parsed, so an oversized list costs nothing more + # than reading the request. def validate_redirect_uris - unless performed? || params[:redirect_uris].present? - oauth_error "invalid_client_metadata", "redirect_uris is required" + unless performed? + if params[:redirect_uris].blank? + oauth_error "invalid_client_metadata", "redirect_uris is required" + elsif !bounded_redirect_uris?(params[:redirect_uris]) + oauth_error "invalid_client_metadata", "redirect_uris may list at most #{Oauth::Client::MAXIMUM_REDIRECT_URIS} URIs of at most #{Oauth::Client::MAXIMUM_REDIRECT_URI_LENGTH} characters each" + end end end + # Anything but a list of strings is refused as unregistrable afterwards. + def bounded_redirect_uris?(uris) + !uris.is_a?(Array) || (uris.size <= Oauth::Client::MAXIMUM_REDIRECT_URIS && + uris.all? { |uri| !uri.is_a?(String) || uri.length <= Oauth::Client::MAXIMUM_REDIRECT_URI_LENGTH }) + end + def validate_loopback_uris unless performed? || all_loopback_uris?(params[:redirect_uris]) oauth_error "invalid_redirect_uri", "Only loopback redirect URIs are allowed for dynamic registration" diff --git a/app/models/oauth/client.rb b/app/models/oauth/client.rb index c5dce738b7..2dd91a4b4b 100644 --- a/app/models/oauth/client.rb +++ b/app/models/oauth/client.rb @@ -5,7 +5,12 @@ class Oauth::Client < ApplicationRecord validates :name, presence: true, length: { maximum: 255 } validates :client_id, uniqueness: true, allow_nil: true - validates :redirect_uris, presence: true + # Registration is open and unauthenticated, so what it stores is bounded: + # a few callbacks, each no longer than a URL browsers reliably carry. + MAXIMUM_REDIRECT_URIS = 10 + MAXIMUM_REDIRECT_URI_LENGTH = 2000 + + validates :redirect_uris, presence: true, length: { maximum: MAXIMUM_REDIRECT_URIS } validate :redirect_uris_are_valid attribute :redirect_uris, default: -> { [] } @@ -30,7 +35,11 @@ def allows_scope?(requested_scope) private def redirect_uris_are_valid - redirect_uris.each { |uri| validate_redirect_uri(uri) } + if redirect_uris.all? { |uri| uri.is_a?(String) && uri.length <= MAXIMUM_REDIRECT_URI_LENGTH } + redirect_uris.each { |uri| validate_redirect_uri(uri) } + else + errors.add :redirect_uris, "must each be a URI of at most #{MAXIMUM_REDIRECT_URI_LENGTH} characters" + end end def validate_redirect_uri(uri) diff --git a/test/integration/oauth_flow_test.rb b/test/integration/oauth_flow_test.rb index 060a62bc88..488dfcc46e 100644 --- a/test/integration/oauth_flow_test.rb +++ b/test/integration/oauth_flow_test.rb @@ -796,6 +796,23 @@ class OauthFlowTest < ActionDispatch::IntegrationTest assert_equal %w[ read write ], Oauth::Client.find_by(client_id: response.parsed_body["client_id"]).scopes end + test "DCR refuses more redirect URIs, or longer ones, than a client needs" do + many = (1..11).map { |port| "http://127.0.0.1:#{8000 + port}/callback" } + long = "http://127.0.0.1:8888/#{"a" * 2000}" + + [ many, [ long ] ].each do |redirect_uris| + assert_no_difference "Oauth::Client.count" do + untenanted { post oauth_clients_path, params: { client_name: "Big", redirect_uris: redirect_uris }, as: :json } + end + + assert_response :bad_request + assert_equal "invalid_client_metadata", response.parsed_body["error"] + end + + untenanted { post oauth_clients_path, params: { client_name: "Ten", redirect_uris: many.first(10) }, as: :json } + assert_response :created + end + test "DCR rejects non-loopback redirect" do assert_no_difference "Oauth::Client.count" do untenanted do diff --git a/test/models/oauth/client_test.rb b/test/models/oauth/client_test.rb index c55549af1e..e4a0d14207 100644 --- a/test/models/oauth/client_test.rb +++ b/test/models/oauth/client_test.rb @@ -169,4 +169,10 @@ class Oauth::ClientTest < ActiveSupport::TestCase assert_equal [ token ], client.access_tokens.to_a end + + test "redirect URIs are bounded in number and length" do + assert_not Oauth::Client.new(name: "Many", redirect_uris: (1..11).map { |port| "http://127.0.0.1:#{port}/cb" }).valid? + assert_not Oauth::Client.new(name: "Long", redirect_uris: [ "http://127.0.0.1/#{"a" * 2000}" ]).valid? + assert Oauth::Client.new(name: "Ten", redirect_uris: (1..10).map { |port| "http://127.0.0.1:#{port}/cb" }).valid? + end end From f64ab634ab6ea1737e48722b83dbefabc13299de Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 17 Oct 2026 01:07:55 -0700 Subject: [PATCH 22/26] Issue a code only for an S256-shaped code_challenge Presence let an array through, which the code then carried until its exchange compared it with the verifier and raised. A challenge is now the 43-character unpadded base64url SHA-256 of a verifier, which also bounds what the code carries. --- .../oauth/authorizations_controller.rb | 6 +++++ test/integration/oauth_flow_test.rb | 23 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb index b7f63da033..2345ddbed8 100644 --- a/app/controllers/oauth/authorizations_controller.rb +++ b/app/controllers/oauth/authorizations_controller.rb @@ -2,6 +2,8 @@ class Oauth::AuthorizationsController < Oauth::BaseController FORM_ACTION_ORIGIN = %r{\Ahttps?://[a-z0-9.-]+(:\d+)?\z}i FORM_ACTION_SCHEME = /\A[a-z][a-z0-9+.-]*\z/i STATE_MAXIMUM_LENGTH = 2048 + # An S256 challenge is the unpadded base64url SHA-256 of the verifier: 43 characters (RFC 7636 §4.2). + S256_CODE_CHALLENGE = /\A[A-Za-z0-9_-]{43}\z/ RETURN_URL_MAXIMUM_LENGTH = 2000 # Browsers apply form-action to every hop of a form submission, redirects @@ -84,6 +86,10 @@ def validate_pkce redirect_with_error "invalid_request", "code_challenge is required" end + unless performed? || params[:code_challenge].is_a?(String) && params[:code_challenge].match?(S256_CODE_CHALLENGE) + redirect_with_error "invalid_request", "code_challenge must be an S256 challenge" + end + unless performed? || params[:code_challenge_method] == "S256" redirect_with_error "invalid_request", "code_challenge_method must be S256" end diff --git a/test/integration/oauth_flow_test.rb b/test/integration/oauth_flow_test.rb index 488dfcc46e..3c84be5ce5 100644 --- a/test/integration/oauth_flow_test.rb +++ b/test/integration/oauth_flow_test.rb @@ -215,6 +215,29 @@ class OauthFlowTest < ActionDispatch::IntegrationTest assert_nil session[:return_to_after_authenticating] end + test "authorization refuses a code_challenge that isn't an S256 challenge" do + sign_in_as :david + client = oauth_clients(:mcp_client) + + [ [ "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" ], "too-short", "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM=" ].each do |code_challenge| + untenanted do + post oauth_authorization_path, params: { + client_id: client.client_id, + redirect_uri: "http://127.0.0.1:8888/callback", + response_type: "code", + code_challenge: code_challenge, + code_challenge_method: "S256", + scope: "read" + } + end + + assert_response :redirect + params = CGI.parse(URI.parse(response.location).query) + assert_equal "invalid_request", params["error"].first, code_challenge.inspect + assert_not params.key?("code") + end + end + test "authorization refuses a state that isn't a single string" do sign_in_as :david client = oauth_clients(:mcp_client) From 07108bb69e528c467db0e759f8aa1d6c478ac699 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 24 Oct 2026 17:15:24 -0700 Subject: [PATCH 23/26] Make the OAuth migrations safe to run again after stopping partway MySQL commits each DDL statement on its own, so a migration of several statements that stops partway (a lock wait, a lost connection) leaves the first ones applied and its version unrecorded. Running it again then fails on the first one. Each step now skips itself when it's already done: create_table, add_column, add_reference and add_index with if_not_exists. The DDL they emit is unchanged. --- db/migrate/20251231163456_add_oauth.rb | 6 ++++-- ..._add_authorization_code_jti_to_identity_access_tokens.rb | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/db/migrate/20251231163456_add_oauth.rb b/db/migrate/20251231163456_add_oauth.rb index 74e6c36a26..f37ce8f1f9 100644 --- a/db/migrate/20251231163456_add_oauth.rb +++ b/db/migrate/20251231163456_add_oauth.rb @@ -1,6 +1,8 @@ +# Each step is skipped when it's already done, so a run that stopped partway +# (MySQL commits each DDL statement on its own) can simply be run again. class AddOauth < ActiveRecord::Migration[8.2] def change - create_table :oauth_clients, id: :uuid do |t| + create_table :oauth_clients, id: :uuid, if_not_exists: true do |t| t.string :client_id, null: false t.string :name, null: false t.json :redirect_uris @@ -13,6 +15,6 @@ def change t.index :client_id, unique: true end - add_reference :identity_access_tokens, :oauth_client, type: :uuid, foreign_key: false + add_reference :identity_access_tokens, :oauth_client, type: :uuid, foreign_key: false, if_not_exists: true end end diff --git a/db/migrate/20260827090000_add_authorization_code_jti_to_identity_access_tokens.rb b/db/migrate/20260827090000_add_authorization_code_jti_to_identity_access_tokens.rb index 75bf6a3112..74a72fc46a 100644 --- a/db/migrate/20260827090000_add_authorization_code_jti_to_identity_access_tokens.rb +++ b/db/migrate/20260827090000_add_authorization_code_jti_to_identity_access_tokens.rb @@ -1,6 +1,8 @@ +# Each step is skipped when it's already done, so a run that stopped partway +# (MySQL commits each DDL statement on its own) can simply be run again. class AddAuthorizationCodeJtiToIdentityAccessTokens < ActiveRecord::Migration[8.2] def change - add_column :identity_access_tokens, :authorization_code_jti, :string - add_index :identity_access_tokens, :authorization_code_jti, unique: true + add_column :identity_access_tokens, :authorization_code_jti, :string, if_not_exists: true + add_index :identity_access_tokens, :authorization_code_jti, unique: true, if_not_exists: true end end From e0c0cf00e2ed164f953756da659ef1691a4bc5a8 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 24 Oct 2026 18:26:46 -0700 Subject: [PATCH 24/26] Answer 404 from a dark endpoint however many times it's asked rate_limit is a before_action, and it came before require_issuance_enabled, so a dark server answered registration's eleventh request, and the token endpoint's twenty-first, with 429 slow_down. The availability gate now runs first. --- app/controllers/oauth/clients_controller.rb | 5 +++-- app/controllers/oauth/tokens_controller.rb | 5 +++-- test/integration/oauth_availability_test.rb | 12 ++++++++++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/app/controllers/oauth/clients_controller.rb b/app/controllers/oauth/clients_controller.rb index d36296904a..3fa0d6a6b5 100644 --- a/app/controllers/oauth/clients_controller.rb +++ b/app/controllers/oauth/clients_controller.rb @@ -2,9 +2,10 @@ class Oauth::ClientsController < Oauth::BaseController allow_unauthenticated_access skip_forgery_protection - rate_limit to: 10, within: 1.minute, only: :create, with: :oauth_rate_limit_exceeded - + # Availability first: rate_limit is a before_action too, so a dark endpoint + # that counted requests first would answer 429 to the eleventh, not 404. before_action :require_issuance_enabled + rate_limit to: 10, within: 1.minute, only: :create, with: :oauth_rate_limit_exceeded before_action :validate_redirect_uris before_action :validate_loopback_uris before_action :validate_auth_method diff --git a/app/controllers/oauth/tokens_controller.rb b/app/controllers/oauth/tokens_controller.rb index 18dd522823..2b1241b459 100644 --- a/app/controllers/oauth/tokens_controller.rb +++ b/app/controllers/oauth/tokens_controller.rb @@ -2,9 +2,10 @@ class Oauth::TokensController < Oauth::BaseController allow_unauthenticated_access skip_forgery_protection - rate_limit to: 20, within: 1.minute, only: :create, with: :oauth_rate_limit_exceeded - + # Availability first: rate_limit is a before_action too, so a dark endpoint + # that counted requests first would answer 429 to the eleventh, not 404. before_action :require_issuance_enabled + rate_limit to: 20, within: 1.minute, only: :create, with: :oauth_rate_limit_exceeded before_action :validate_grant_type before_action :require_params before_action :set_auth_code diff --git a/test/integration/oauth_availability_test.rb b/test/integration/oauth_availability_test.rb index c1de717cc6..dd6b2687ef 100644 --- a/test/integration/oauth_availability_test.rb +++ b/test/integration/oauth_availability_test.rb @@ -46,6 +46,18 @@ class OauthAvailabilityTest < ActionDispatch::IntegrationTest assert_response :not_found end + test "a dark server answers 404 even past the rate limit" do + ActiveSupport::Cache::NullStore.any_instance.stubs(:increment).returns(1_000) + + with_oauth_availability acceptance: false do + untenanted { post oauth_clients_path, params: { redirect_uris: [ "http://127.0.0.1:9999/callback" ] }, as: :json } + assert_response :not_found + + untenanted { post oauth_token_path, params: { grant_type: "authorization_code", code: "x" } } + assert_response :not_found + end + end + test "naming a pilot client does not open registration on a dark server" do with_oauth_availability acceptance: false, issuance: false, pilot_client_ids: [ oauth_clients(:mcp_client).client_id ] do assert_no_difference "Oauth::Client.count" do From 19380374ff9581fb88517932d15e640c2e4a3b8b Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 25 Oct 2026 21:35:26 -0500 Subject: [PATCH 25/26] Bring main's tests up to this branch: Ruby 4 and the access token finder Main runs Ruby 4.0, which drops CGI.parse, so the OAuth flow tests read redirect queries with URI.decode_www_form like main's own. Main's SQL redaction tests look a token up through Identity::AccessToken.find_permissable, the bearer path here. --- test/integration/oauth_flow_test.rb | 34 +++++++++---------- .../active_record_sql_redaction_test.rb | 4 +-- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/test/integration/oauth_flow_test.rb b/test/integration/oauth_flow_test.rb index 3c84be5ce5..8c84a0ba7f 100644 --- a/test/integration/oauth_flow_test.rb +++ b/test/integration/oauth_flow_test.rb @@ -93,9 +93,9 @@ class OauthFlowTest < ActionDispatch::IntegrationTest # Per RFC 6749, redirect to client with error in query params assert_response :redirect - redirect_params = CGI.parse(URI.parse(response.location).query) - assert_equal "invalid_request", redirect_params["error"].first - assert_match "code_challenge", redirect_params["error_description"].first + redirect_params = URI.decode_www_form(URI.parse(response.location).query).to_h + assert_equal "invalid_request", redirect_params["error"] + assert_match "code_challenge", redirect_params["error_description"] end test "authorization consent issues code" do @@ -120,9 +120,9 @@ class OauthFlowTest < ActionDispatch::IntegrationTest assert_equal "127.0.0.1", redirect_uri.host assert_equal "/callback", redirect_uri.path - params = CGI.parse(redirect_uri.query) - assert_not_nil params["code"]&.first - assert_equal "xyz123", params["state"]&.first + params = URI.decode_www_form(redirect_uri.query).to_h + assert_not_nil params["code"] + assert_equal "xyz123", params["state"] end @@ -156,8 +156,8 @@ class OauthFlowTest < ActionDispatch::IntegrationTest end assert_response :redirect - params = CGI.parse(URI.parse(response.location).query) - assert_not_nil params["code"]&.first + params = URI.decode_www_form(URI.parse(response.location).query).to_h + assert_not_nil params["code"] assert_not params.key?("state") end @@ -184,15 +184,15 @@ class OauthFlowTest < ActionDispatch::IntegrationTest end assert_response :redirect - assert_equal "s" * 2048, CGI.parse(URI.parse(response.location).query)["state"].first + assert_equal "s" * 2048, URI.decode_www_form(URI.parse(response.location).query).to_h["state"] untenanted do get new_oauth_authorization_path, params: authorization.merge(state: "s" * 2049) end assert_response :redirect - params = CGI.parse(URI.parse(response.location).query) - assert_equal "invalid_request", params["error"].first + params = URI.decode_www_form(URI.parse(response.location).query).to_h + assert_equal "invalid_request", params["error"] assert_not params.key?("code") end @@ -232,8 +232,8 @@ class OauthFlowTest < ActionDispatch::IntegrationTest end assert_response :redirect - params = CGI.parse(URI.parse(response.location).query) - assert_equal "invalid_request", params["error"].first, code_challenge.inspect + params = URI.decode_www_form(URI.parse(response.location).query).to_h + assert_equal "invalid_request", params["error"], code_challenge.inspect assert_not params.key?("code") end end @@ -255,7 +255,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest end assert_response :redirect - assert_equal "invalid_request", CGI.parse(URI.parse(response.location).query)["error"].first + assert_equal "invalid_request", URI.decode_www_form(URI.parse(response.location).query).to_h["error"] end test "authorization preselects Read + Write when the client requests read write" do @@ -936,10 +936,10 @@ class OauthFlowTest < ActionDispatch::IntegrationTest # Extract code from redirect redirect_uri = URI.parse(response.location) - params = CGI.parse(redirect_uri.query) - code = params["code"].first + params = URI.decode_www_form(redirect_uri.query).to_h + code = params["code"] assert_not_nil code - assert_equal "test_state", params["state"].first + assert_equal "test_state", params["state"] # Step 3: Exchange code for token assert_difference "Identity::AccessToken.count", 1 do diff --git a/test/lib/rails_ext/active_record_sql_redaction_test.rb b/test/lib/rails_ext/active_record_sql_redaction_test.rb index 8ca4c5692e..0ef079a37a 100644 --- a/test/lib/rails_ext/active_record_sql_redaction_test.rb +++ b/test/lib/rails_ext/active_record_sql_redaction_test.rb @@ -49,7 +49,7 @@ class ActiveRecordSqlRedactionTest < ActiveSupport::TestCase test "an access token is absent from its logged insert and lookup" do log, executed = capture do access_token = identities(:kevin).access_tokens.create!(token: TOKEN, permission: "write") - assert_equal identities(:kevin), Identity.find_by_permissable_access_token(TOKEN, method: "POST") + assert_equal identities(:kevin), Identity::AccessToken.find_permissable(TOKEN, method: "POST")&.identity access_token.update!(description: "Made-up script") end @@ -139,7 +139,7 @@ class ActiveRecordSqlRedactionTest < ActiveSupport::TestCase ActiveSupport::Notifications.subscribe(/active_record/) { |_name, _start, _finish, _id, payload| seen << payload.dup } ] - assert_equal identities(:kevin), Identity.find_by_permissable_access_token(identities(:kevin).access_tokens.create!(token: TOKEN).token, method: "GET") + assert_equal identities(:kevin), Identity::AccessToken.find_permissable(identities(:kevin).access_tokens.create!(token: TOKEN).token, method: "GET")&.identity redacted = seen.select { |payload| payload[:sql].to_s.include?("identity_access_tokens") } assert redacted.many? From 0083f5491f426ceafc2aca513bacf885faa11b67 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 25 Oct 2026 23:08:16 -0500 Subject: [PATCH 26/26] Filter the code parameter by its whole name, so code_challenge stays readable Main filters code by partial match for the magic link and join codes, which also caught OAuth's public code_challenge. Match it whole, nested paths included, and it covers the authorization code too. --- config/application.rb | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/config/application.rb b/config/application.rb index d1ad8f4e9d..af85493b8c 100644 --- a/config/application.rb +++ b/config/application.rb @@ -13,22 +13,22 @@ class Application < Rails::Application # Set here rather than in an initializer: Active Record copies these into filter_attributes when it # loads, and config/initializers/extensions.rb loads it before later initializers run. # - # `code` covers the sign-in code a magic link is redeemed with, and a join code's route parameter. A - # webhook's `url` can be a credential itself (a chat service's incoming webhook URL); it is matched - # whole, so a parameter that merely ends in "url" keeps its value. The pattern also matches a nested - # key's dotted path ("webhook.url"), which is what it is tested against once any dotted filter, such as - # those `encrypts` adds, is present. `signature` covers a presigned storage URL's X-Amz-Signature, which - # makes a redirect's logged location a working link to the file until it expires. + # `code` covers the sign-in code a magic link is redeemed with, a join code's route parameter and an + # OAuth authorization code, in the logged redirect to the client too. It and a webhook's `url`, which + # can be a credential itself (a chat service's incoming webhook URL), are matched whole, so OAuth's + # public code_challenge and a parameter that merely ends in "url" keep their values. Each pattern also + # matches a nested key's dotted path ("webhook.url"), which is what it is tested against once any + # dotted filter, such as those `encrypts` adds, is present. `signature` covers a presigned storage URL's + # X-Amz-Signature, which makes a redirect's logged location a working link to the file until it expires. config.filter_parameters += [ - *%i[ passw secret token _key crypt salt certificate otp ssn code signature ], + *%i[ passw secret token _key crypt salt certificate otp ssn signature ], + /(?:\A|\.)code\z/i, /(?:\A|\.)url\z/ ] - # OAuth credentials the list above doesn't reach by partial match: an - # authorization code and its PKCE verifier, client assertions and DPoP - # proofs. `code` is matched whole, so code_challenge, which is public, stays - # readable. This also filters the code from the logged redirect to the client. - config.filter_parameters += [ /\Acode\z/i, :code_verifier, :device_code, :client_assertion, :dpop ] + # OAuth credentials the list above doesn't reach: a PKCE verifier, a device + # code, client assertions and DPoP proofs. + config.filter_parameters += [ :code_verifier, :device_code, :client_assertion, :dpop ] # Include the `lib` directory in autoload paths. Use the `ignore:` option # to list subdirectories that don't contain `.rb` files or that shouldn't