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 29fdab4ef7..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? } @@ -24,6 +24,19 @@ 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: personal access + # tokens, Connected Apps, passkeys, transfer links, email changes and OAuth + # 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 + def disallow_account_scope(**options) skip_before_action :require_account, **options before_action :redirect_tenanted_request, **options @@ -35,6 +48,14 @@ def authenticated? Current.identity.present? end + def oauth_grant? + Current.access_token&.oauth_client_id? + end + + def forbid_oauth_grant + head :forbidden if oauth_grant? + end + def require_account unless Current.account.present? redirect_to main_app.session_menu_path(script_name: nil) @@ -59,8 +80,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 dc7e9826a1..4e836141b3 100644 --- a/app/controllers/my/access_tokens_controller.rb +++ b/app/controllers/my/access_tokens_controller.rb @@ -2,6 +2,7 @@ class My::AccessTokensController < ApplicationController wrap_parameters :access_token, include: %i[ description permission ] skip_before_action :require_account + disallow_oauth_grants def index @access_tokens = my_access_tokens.order(created_at: :desc) @@ -45,7 +46,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/my/connected_apps_controller.rb b/app/controllers/my/connected_apps_controller.rb new file mode 100644 index 0000000000..94da5900e2 --- /dev/null +++ b/app/controllers/my/connected_apps_controller.rb @@ -0,0 +1,34 @@ +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 + 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/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 new file mode 100644 index 0000000000..2345ddbed8 --- /dev/null +++ b/app/controllers/oauth/authorizations_controller.rb @@ -0,0 +1,170 @@ +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 + # 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 + + # 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 + disallow_oauth_grants + + 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 = Oauth.canonical_scope(params[:scope].presence || "read") + @redirect_uri = params[:redirect_uri] + @state = params[:state] + @code_challenge = params[:code_challenge] + end + + def create + if denial? + 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: Oauth.canonical_scope(params[:scope].presence || "read") + + redirect_to success_redirect_uri(code), allow_other_host: true + end + end + + private + 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 + 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 + @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].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 + 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 + + # 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? || 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 + 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, + 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, + 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) + 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..21e253ece4 --- /dev/null +++ b/app/controllers/oauth/base_controller.rb @@ -0,0 +1,42 @@ +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?(piloting_client_id) + head :not_found + 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 + 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 + + def oauth_rate_limit_exceeded + oauth_error "slow_down", "Too many requests", status: :too_many_requests + end +end diff --git a/app/controllers/oauth/clients_controller.rb b/app/controllers/oauth/clients_controller.rb new file mode 100644 index 0000000000..3fa0d6a6b5 --- /dev/null +++ b/app/controllers/oauth/clients_controller.rb @@ -0,0 +1,100 @@ +class Oauth::ClientsController < Oauth::BaseController + allow_unauthenticated_access + skip_forgery_protection + + # 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 + + def create + client = Oauth::Client.create! \ + name: registered_client_name, + 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 + # 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 + + # Bounded before any URI is parsed, so an oversized list costs nothing more + # than reading the request. + def validate_redirect_uris + 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" + 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 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 + when Array then params[:scope].select { |s| s.is_a?(String) } + else [] + end + Oauth.canonical_scope(requested.join(" ")).split + 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..08eea40a58 --- /dev/null +++ b/app/controllers/oauth/metadata_controller.rb @@ -0,0 +1,23 @@ +class Oauth::MetadataController < Oauth::BaseController + allow_unauthenticated_access + + before_action :require_acceptance_enabled + + def show + render json: { + issuer: oauth_issuer, + authorization_endpoint: new_oauth_authorization_url, + 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 ], + 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 new file mode 100644 index 0000000000..0fa8767efa --- /dev/null +++ b/app/controllers/oauth/protected_resource_metadata_controller.rb @@ -0,0 +1,14 @@ +class Oauth::ProtectedResourceMetadataController < Oauth::BaseController + allow_unauthenticated_access + + before_action :require_acceptance_enabled + + def show + render json: { + resource: root_url(script_name: nil), + authorization_servers: [ oauth_issuer ], + bearer_methods_supported: %w[ header ], + scopes_supported: %w[ read write ] + } + end +end diff --git a/app/controllers/oauth/revocations_controller.rb b/app/controllers/oauth/revocations_controller.rb new file mode 100644 index 0000000000..733f386a20 --- /dev/null +++ b/app/controllers/oauth/revocations_controller.rb @@ -0,0 +1,21 @@ +class Oauth::RevocationsController < Oauth::BaseController + allow_unauthenticated_access + skip_forgery_protection + + 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 + 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/controllers/oauth/tokens_controller.rb b/app/controllers/oauth/tokens_controller.rb new file mode 100644 index 0000000000..2b1241b459 --- /dev/null +++ b/app/controllers/oauth/tokens_controller.rb @@ -0,0 +1,93 @@ +class Oauth::TokensController < Oauth::BaseController + allow_unauthenticated_access + skip_forgery_protection + + # 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 + before_action :set_client + before_action :validate_client_id + before_action :validate_pkce + before_action :validate_redirect_uri + before_action :set_identity + + def create + 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 + } + else + oauth_error "invalid_grant", "Authorization code already used" + end + end + + private + def validate_grant_type + 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. + # 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 ].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" + 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 + + # 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" + 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/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/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/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/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 752544d928..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.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 abdf37eba8..a330887bfb 100644 --- a/app/models/identity/access_token.rb +++ b/app/models/identity/access_token.rb @@ -1,9 +1,38 @@ 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 + 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. + 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 + + # 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.rb b/app/models/oauth.rb new file mode 100644 index 0000000000..f824442f28 --- /dev/null +++ b/app/models/oauth.rb @@ -0,0 +1,13 @@ +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 +end diff --git a/app/models/oauth/authorization_code.rb b/app/models/oauth/authorization_code.rb new file mode 100644 index 0000000000..5cb6ca4113 --- /dev/null +++ b/app/models/oauth/authorization_code.rb @@ -0,0 +1,43 @@ +# 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 + def generate(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)) && 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"], + jti: data["jti"] + end + rescue ActiveSupport::MessageEncryptor::InvalidMessage, ActiveSupport::MessageVerifier::InvalidSignature + nil + end + + def valid_pkce?(code_data, code_verifier) + code_data && code_verifier.is_a?(String) && code_verifier.match?(VERIFIER) && + 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/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/app/models/oauth/client.rb b/app/models/oauth/client.rb new file mode 100644 index 0000000000..2dd91a4b4b --- /dev/null +++ b/app/models/oauth/client.rb @@ -0,0 +1,88 @@ +class Oauth::Client < ApplicationRecord + has_many :access_tokens, class_name: "Identity::AccessToken", foreign_key: :oauth_client_id, inverse_of: :oauth_client + + has_secure_token :client_id, length: 32 + + validates :name, presence: true, length: { maximum: 255 } + validates :client_id, uniqueness: true, allow_nil: 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: -> { [] } + 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 + 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) + 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 + + # 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) + parsed = URI.parse(uri) + + redirect_uris.any? do |redirect_uri| + redirect = URI.parse(redirect_uri) + + redirect.scheme == "http" && parsed.scheme == "http" && + redirect.host.in?(Oauth::LOOPBACK_HOSTS) && + redirect.host.casecmp?(parsed.host.to_s) && + redirect.path == parsed.path && + redirect.query == parsed.query + end + rescue URI::InvalidURIError + false + 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..1e39adf810 --- /dev/null +++ b/app/views/my/connected_apps/index.html.erb @@ -0,0 +1,36 @@ +<% @page_title = "Connected apps" %> + +<% content_for :header do %> +
+ <% 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 %>

+<% 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/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..a37a0d24e5 --- /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" => "read 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/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/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/config/application.rb b/config/application.rb index 16d1b41c48..af85493b8c 100644 --- a/config/application.rb +++ b/config/application.rb @@ -13,17 +13,23 @@ 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: 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 # be reloaded or eager loaded. 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 f8c9ae59ed..ebe3b22cae 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/config/routes.rb b/config/routes.rb index 2d6dff48bb..6776ffa3db 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,16 @@ 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 resource :cancellation, only: [ :create ] resource :entropy @@ -192,6 +202,7 @@ filter_path_parameters :id do resources :access_tokens end + resources :connected_apps, only: %i[ index destroy ] resources :passkeys, except: %i[ show new ] resources :pins resource :timezone diff --git a/db/migrate/20251231163456_add_oauth.rb b/db/migrate/20251231163456_add_oauth.rb new file mode 100644 index 0000000000..f37ce8f1f9 --- /dev/null +++ b/db/migrate/20251231163456_add_oauth.rb @@ -0,0 +1,20 @@ +# 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, if_not_exists: true 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, 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 new file mode 100644 index 0000000000..74a72fc46a --- /dev/null +++ b/db/migrate/20260827090000_add_authorization_code_jti_to_identity_access_tokens.rb @@ -0,0 +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, if_not_exists: true + add_index :identity_access_tokens, :authorization_code_jti, unique: true, if_not_exists: true + end +end diff --git a/db/schema.rb b/db/schema.rb index c706383b4b..d5f52d7830 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -352,13 +352,17 @@ 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 + t.uuid "oauth_client_id" 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 create_table "identity_transfers", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -429,6 +433,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 diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index 312261878f..4a57506cd9 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -352,13 +352,17 @@ 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 + t.uuid "oauth_client_id" 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 create_table "identity_transfers", id: :uuid, force: :cascade do |t| @@ -429,6 +433,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/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/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/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}" } 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..9eb5eb9dd4 --- /dev/null +++ b/test/controllers/my/connected_apps_controller_test.rb @@ -0,0 +1,78 @@ +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 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") + + 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 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_availability_test.rb b/test/integration/oauth_availability_test.rb new file mode 100644 index 0000000000..dd6b2687ef --- /dev/null +++ b/test/integration/oauth_availability_test.rb @@ -0,0 +1,271 @@ +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 "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 + 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 + 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/integration/oauth_flow_test.rb b/test/integration/oauth_flow_test.rb new file mode 100644 index 0000000000..8c84a0ba7f --- /dev/null +++ b/test/integration/oauth_flow_test.rb @@ -0,0 +1,1008 @@ +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 = 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 + 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 = URI.decode_www_form(redirect_uri.query).to_h + assert_not_nil params["code"] + assert_equal "xyz123", params["state"] + 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 = URI.decode_www_form(URI.parse(response.location).query).to_h + assert_not_nil params["code"] + 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, 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 = URI.decode_www_form(URI.parse(response.location).query).to_h + assert_equal "invalid_request", params["error"] + 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 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 = 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 + + 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", 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 + 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 + 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", + 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 + 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 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: { + 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" + }, 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", + client_id: oauth_clients(:mcp_client).client_id, + 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", + client_id: oauth_clients(:mcp_client).client_id, + 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", + 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 + 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 + + 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 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) + + 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" + 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", + client_id: oauth_clients(:mcp_client).client_id, + 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 + 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 + 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 + + + # 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" + 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 + 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 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 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 + 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 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 + 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 = URI.decode_www_form(redirect_uri.query).to_h + code = params["code"] + assert_not_nil code + assert_equal "test_state", params["state"] + + # Step 3: Exchange code for token + assert_difference "Identity::AccessToken.count", 1 do + 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 + end + + assert_response :success + body = response.parsed_body + assert_not_nil body["access_token"] + assert_equal "Bearer", body["token_type"] + 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, + 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 + ensure + ActionController::Base.allow_forgery_protection = false + end +end diff --git a/test/integration/oauth_grant_reach_test.rb b/test/integration/oauth_grant_reach_test.rb new file mode 100644 index 0000000000..bf60286619 --- /dev/null +++ b/test/integration/oauth_grant_reach_test.rb @@ -0,0 +1,138 @@ +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 "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 "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}" } + + get my_access_tokens_path, env: pat, as: :json + + 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 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 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? diff --git a/test/models/oauth/authorization_code_test.rb b/test/models/oauth/authorization_code_test.rb new file mode 100644 index 0000000000..10b6e5141d --- /dev/null +++ b/test/models/oauth/authorization_code_test.rb @@ -0,0 +1,181 @@ +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 "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) + 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", + jti: "test-jti" + + 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) + + 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", + jti: "test-jti" + + 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", + jti: "test-jti" + + 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", + jti: "test-jti" + + assert_raises(FrozenError) { details.instance_variable_set(:@client_id, "hacked") } + 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/models/oauth/client_test.rb b/test/models/oauth/client_test.rb new file mode 100644 index 0000000000..e4a0d14207 --- /dev/null +++ b/test/models/oauth/client_test.rb @@ -0,0 +1,178 @@ +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") + 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? 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") + 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 + + 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 + + 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 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 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