diff --git a/app/controllers/my/access_tokens_controller.rb b/app/controllers/my/access_tokens_controller.rb index 99c87893f7..5d68a33f03 100644 --- a/app/controllers/my/access_tokens_controller.rb +++ b/app/controllers/my/access_tokens_controller.rb @@ -27,7 +27,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..322451db7e --- /dev/null +++ b/app/controllers/my/connected_apps_controller.rb @@ -0,0 +1,28 @@ +class My::ConnectedAppsController < ApplicationController + before_action :set_connected_apps, only: :index + before_action :set_oauth_client, only: :destroy + + def index + end + + def destroy + @tokens.destroy_all + + redirect_to my_connected_apps_path, notice: "#{@client.name} has been disconnected" + end + + private + def set_connected_apps + tokens = oauth_tokens.includes(:oauth_client).order(:created_at) + @connected_apps = tokens.group_by(&:oauth_client).sort_by { |client, _| client.name.downcase } + end + + def set_oauth_client + @tokens = oauth_tokens.where(oauth_client_id: params.require(:id)) + @client = @tokens.first&.oauth_client or raise ActiveRecord::RecordNotFound + end + + def oauth_tokens + Current.identity.access_tokens.oauth + end +end diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb new file mode 100644 index 0000000000..bb605e0acb --- /dev/null +++ b/app/controllers/oauth/authorizations_controller.rb @@ -0,0 +1,108 @@ +class Oauth::AuthorizationsController < Oauth::BaseController + before_action :save_oauth_return_url + before_action :require_authentication + + before_action :set_client + before_action :validate_redirect_uri + before_action :validate_response_type + before_action :validate_pkce + before_action :validate_scope + before_action :validate_state + + def new + @scope = params[:scope].presence || "read" + @redirect_uri = params[:redirect_uri] + @state = params[:state] + @code_challenge = params[:code_challenge] + end + + def create + if params[:error] == "access_denied" + redirect_to error_redirect_uri("access_denied", "User denied the request"), allow_other_host: true + else + code = Oauth::AuthorizationCode.generate \ + client_id: @client.client_id, + identity_id: Current.identity.id, + code_challenge: params[:code_challenge], + redirect_uri: params[:redirect_uri], + scope: params[:scope].presence || "read" + + redirect_to success_redirect_uri(code), allow_other_host: true + end + end + + private + def save_oauth_return_url + session[:return_to_after_authenticating] = request.url if request.get? && !authenticated? + end + + def set_client + @client = Oauth::Client.find_by(client_id: params[:client_id]) + oauth_error("invalid_request", "Unknown client") unless @client + end + + def validate_redirect_uri + unless performed? || @client.allows_redirect?(params[:redirect_uri]) + redirect_with_error "invalid_request", "Invalid redirect_uri" + end + end + + def validate_response_type + unless performed? || params[:response_type] == "code" + redirect_with_error "unsupported_response_type", "Only 'code' response_type is supported" + end + end + + def validate_pkce + unless performed? || params[:code_challenge].present? + redirect_with_error "invalid_request", "code_challenge is required" + end + + unless performed? || params[:code_challenge_method] == "S256" + redirect_with_error "invalid_request", "code_challenge_method must be S256" + end + end + + def validate_scope + unless performed? || @client.allows_scope?(params[:scope].presence || "read") + redirect_with_error "invalid_scope", "Requested scope is not allowed" + end + end + + def validate_state + unless performed? || params[:state].present? + redirect_with_error "invalid_request", "state is required" + end + end + + def redirect_with_error(error, description) + if params[:redirect_uri].present? && @client&.allows_redirect?(params[:redirect_uri]) + redirect_to error_redirect_uri(error, description), allow_other_host: true + else + @error = error + @error_description = description + render :error, status: :bad_request + end + end + + def success_redirect_uri(code) + build_redirect_uri params[:redirect_uri], + code: code, + state: params[:state].presence + end + + def error_redirect_uri(error, description) + build_redirect_uri params[:redirect_uri], + error: error, + error_description: description, + state: params[:state].presence + end + + def build_redirect_uri(base, **query_params) + uri = URI.parse(base) + query = URI.decode_www_form(uri.query || "") + query_params.compact.each { |k, v| query << [ k.to_s, v ] } + uri.query = URI.encode_www_form(query) + uri.to_s + end +end diff --git a/app/controllers/oauth/base_controller.rb b/app/controllers/oauth/base_controller.rb new file mode 100644 index 0000000000..658d1e2d48 --- /dev/null +++ b/app/controllers/oauth/base_controller.rb @@ -0,0 +1,12 @@ +class Oauth::BaseController < ApplicationController + disallow_account_scope + + private + def oauth_error(error, description = nil, status: :bad_request) + render json: { error: error, error_description: description }.compact, status: status + end + + def oauth_rate_limit_exceeded + oauth_error "slow_down", "Too many requests", status: :too_many_requests + end +end diff --git a/app/controllers/oauth/clients_controller.rb b/app/controllers/oauth/clients_controller.rb new file mode 100644 index 0000000000..df0c13f1a6 --- /dev/null +++ b/app/controllers/oauth/clients_controller.rb @@ -0,0 +1,75 @@ +class Oauth::ClientsController < Oauth::BaseController + allow_unauthenticated_access + + rate_limit to: 10, within: 1.minute, only: :create, with: :oauth_rate_limit_exceeded + + before_action :validate_redirect_uris + before_action :validate_loopback_uris + before_action :validate_auth_method + + def create + client = Oauth::Client.create! \ + name: params[:client_name] || "MCP Client", + redirect_uris: Array(params[:redirect_uris]), + scopes: validated_scopes, + dynamically_registered: true + + render json: dynamic_client_registration_response(client), status: :created + rescue ActiveRecord::RecordInvalid => e + oauth_error "invalid_client_metadata", e.message + end + + private + def validate_redirect_uris + unless performed? || params[:redirect_uris].present? + oauth_error "invalid_client_metadata", "redirect_uris is required" + end + end + + def validate_loopback_uris + unless performed? || all_loopback_uris?(params[:redirect_uris]) + oauth_error "invalid_redirect_uri", "Only loopback redirect URIs are allowed for dynamic registration" + end + end + + def validate_auth_method + unless performed? || params[:token_endpoint_auth_method].blank? || params[:token_endpoint_auth_method] == "none" + oauth_error "invalid_client_metadata", "Only 'none' token_endpoint_auth_method is supported" + end + end + + def all_loopback_uris?(uris) + uris.is_a?(Array) && + uris.all? { |uri| uri.is_a?(String) && valid_loopback_uri?(uri) } + end + + def valid_loopback_uri?(uri) + parsed = URI.parse(uri) + parsed.scheme == "http" && + Oauth::LOOPBACK_HOSTS.include?(parsed.host) && + parsed.fragment.nil? + rescue URI::InvalidURIError + false + end + + def validated_scopes + requested = case params[:scope] + when String then params[:scope].split + when Array then params[:scope].select { |s| s.is_a?(String) } + else [] + end + requested.select { |s| s.presence_in %w[ read write ] }.presence || %w[ read ] + end + + def dynamic_client_registration_response(client) + { + client_id: client.client_id, + client_name: client.name, + redirect_uris: client.redirect_uris, + token_endpoint_auth_method: "none", + grant_types: %w[ authorization_code ], + response_types: %w[ code ], + scope: client.scopes.join(" ") + } + end +end diff --git a/app/controllers/oauth/metadata_controller.rb b/app/controllers/oauth/metadata_controller.rb new file mode 100644 index 0000000000..9bc84ea15e --- /dev/null +++ b/app/controllers/oauth/metadata_controller.rb @@ -0,0 +1,18 @@ +class Oauth::MetadataController < Oauth::BaseController + allow_unauthenticated_access + + def show + render json: { + issuer: root_url(script_name: nil), + authorization_endpoint: new_oauth_authorization_url, + token_endpoint: oauth_token_url, + registration_endpoint: oauth_clients_url, + revocation_endpoint: oauth_revocation_url, + response_types_supported: %w[ code ], + grant_types_supported: %w[ authorization_code ], + token_endpoint_auth_methods_supported: %w[ none ], + code_challenge_methods_supported: %w[ S256 ], + scopes_supported: %w[ read write ] + } + end +end diff --git a/app/controllers/oauth/protected_resource_metadata_controller.rb b/app/controllers/oauth/protected_resource_metadata_controller.rb new file mode 100644 index 0000000000..d7f6d4895d --- /dev/null +++ b/app/controllers/oauth/protected_resource_metadata_controller.rb @@ -0,0 +1,12 @@ +class Oauth::ProtectedResourceMetadataController < Oauth::BaseController + allow_unauthenticated_access + + def show + render json: { + resource: root_url(script_name: nil), + authorization_servers: [ root_url(script_name: nil) ], + bearer_methods_supported: %w[ header ], + scopes_supported: %w[ read write ] + } + end +end diff --git a/app/controllers/oauth/revocations_controller.rb b/app/controllers/oauth/revocations_controller.rb new file mode 100644 index 0000000000..6dca9777d3 --- /dev/null +++ b/app/controllers/oauth/revocations_controller.rb @@ -0,0 +1,16 @@ +class Oauth::RevocationsController < Oauth::BaseController + allow_unauthenticated_access + + before_action :set_access_token + + def create + @access_token&.destroy + + head :ok # Don't behave as oracle, per RFC 7009 + end + + private + def set_access_token + @access_token = Identity::AccessToken.find_by(token: params.require(:token)) + end +end diff --git a/app/controllers/oauth/tokens_controller.rb b/app/controllers/oauth/tokens_controller.rb new file mode 100644 index 0000000000..11cfefce0b --- /dev/null +++ b/app/controllers/oauth/tokens_controller.rb @@ -0,0 +1,61 @@ +class Oauth::TokensController < Oauth::BaseController + allow_unauthenticated_access + + rate_limit to: 20, within: 1.minute, only: :create, with: :oauth_rate_limit_exceeded + + before_action :validate_grant_type + before_action :set_auth_code + before_action :set_client + before_action :validate_pkce + before_action :validate_redirect_uri + before_action :set_identity + + def create + granted = @auth_code.scope.to_s.split + permission = granted.include?("write") ? "write" : "read" + access_token = @identity.access_tokens.create! oauth_client: @client, permission: permission + + render json: { + access_token: access_token.token, + token_type: "Bearer", + scope: granted.join(" ") + } + end + + private + def validate_grant_type + unless params[:grant_type] == "authorization_code" + oauth_error "unsupported_grant_type", "Only authorization_code grant is supported" + end + end + + def set_auth_code + unless @auth_code = Oauth::AuthorizationCode.parse(params[:code]) + oauth_error "invalid_grant", "Invalid or expired authorization code" + end + end + + def set_client + unless @client = Oauth::Client.find_by(client_id: @auth_code.client_id) + oauth_error "invalid_grant", "Unknown client" + end + end + + def validate_pkce + unless Oauth::AuthorizationCode.valid_pkce?(@auth_code, params[:code_verifier]) + oauth_error "invalid_grant", "PKCE verification failed" + end + end + + def validate_redirect_uri + unless @auth_code.redirect_uri == params[:redirect_uri] + oauth_error "invalid_grant", "redirect_uri mismatch" + end + end + + def set_identity + unless @identity = Identity.find_by(id: @auth_code.identity_id) + oauth_error "invalid_grant", "Identity not found" + end + end +end diff --git a/app/models/identity/access_token.rb b/app/models/identity/access_token.rb index abdf37eba8..6cd1fc3b24 100644 --- a/app/models/identity/access_token.rb +++ b/app/models/identity/access_token.rb @@ -1,5 +1,9 @@ class Identity::AccessToken < ApplicationRecord belongs_to :identity + belongs_to :oauth_client, class_name: "Oauth::Client", optional: true + + scope :personal, -> { where oauth_client_id: nil } + scope :oauth, -> { where.not oauth_client_id: nil } has_secure_token enum :permission, %w[ read write ].index_by(&:itself), default: :read diff --git a/app/models/oauth.rb b/app/models/oauth.rb new file mode 100644 index 0000000000..5770882e85 --- /dev/null +++ b/app/models/oauth.rb @@ -0,0 +1,7 @@ +module Oauth + LOOPBACK_HOSTS = %w[ 127.0.0.1 localhost ::1 [::1] ] + + def self.table_name_prefix + "oauth_" + end +end diff --git a/app/models/oauth/authorization_code.rb b/app/models/oauth/authorization_code.rb new file mode 100644 index 0000000000..32460b57f1 --- /dev/null +++ b/app/models/oauth/authorization_code.rb @@ -0,0 +1,38 @@ +module Oauth::AuthorizationCode + Details = ::Data.define(:client_id, :identity_id, :code_challenge, :redirect_uri, :scope) + + class << self + def generate(client_id:, identity_id:, code_challenge:, redirect_uri:, scope:) + payload = { client_id:, identity_id:, code_challenge:, redirect_uri:, scope: } + encryptor.encrypt_and_sign(payload, expires_in: 60.seconds) + end + + def parse(code) + if code.present? && data = encryptor.decrypt_and_verify(code) + Details.new \ + client_id: data["client_id"], + identity_id: data["identity_id"], + code_challenge: data["code_challenge"], + redirect_uri: data["redirect_uri"], + scope: data["scope"] + end + rescue ActiveSupport::MessageEncryptor::InvalidMessage, ActiveSupport::MessageVerifier::InvalidSignature + nil + end + + def valid_pkce?(code_data, code_verifier) + code_data && code_verifier.present? && + ActiveSupport::SecurityUtils.secure_compare(pkce_challenge(code_verifier), code_data.code_challenge) + end + + private + def encryptor + @encryptor ||= ActiveSupport::MessageEncryptor.new \ + Rails.application.key_generator.generate_key("oauth/authorization_codes", 32) + end + + def pkce_challenge(verifier) + Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false) + end + end +end diff --git a/app/models/oauth/client.rb b/app/models/oauth/client.rb new file mode 100644 index 0000000000..dd29156169 --- /dev/null +++ b/app/models/oauth/client.rb @@ -0,0 +1,74 @@ +class Oauth::Client < ApplicationRecord + has_many :access_tokens, class_name: "Identity::AccessToken" + + has_secure_token :client_id, length: 32 + + validates :name, presence: true + validates :client_id, uniqueness: true, allow_nil: true + validates :redirect_uris, presence: true + validate :redirect_uris_are_valid + + attribute :redirect_uris, default: -> { [] } + attribute :scopes, default: -> { %w[ read ] } + + scope :trusted, -> { where trusted: true } + scope :dynamically_registered, -> { where dynamically_registered: true } + + + def loopback? + redirect_uris.all? { |uri| loopback_uri?(uri) } + end + + def allows_redirect?(uri) + redirect_uris.include?(uri) || (loopback? && loopback_uri?(uri) && matching_loopback?(uri)) + end + + def allows_scope?(requested_scope) + requested = requested_scope.to_s.split + requested.present? && requested.all? { |s| scopes.include?(s) } + end + + private + def redirect_uris_are_valid + redirect_uris.each { |uri| validate_redirect_uri(uri) } + end + + def validate_redirect_uri(uri) + parsed = URI.parse(uri) + + if parsed.fragment.present? + errors.add :redirect_uris, "must not contain fragments" + end + + if dynamically_registered? && !valid_loopback_uri?(parsed) + errors.add :redirect_uris, "must be a local loopback URI for dynamically registered clients" + end + rescue URI::InvalidURIError + errors.add :redirect_uris, "includes an invalid URI" + end + + def loopback_uri?(uri) + Oauth::LOOPBACK_HOSTS.include?(URI.parse(uri).host) + rescue URI::InvalidURIError + false + end + + def valid_loopback_uri?(parsed) + parsed.scheme == "http" && parsed.host.in?(Oauth::LOOPBACK_HOSTS) + end + + def matching_loopback?(uri) + parsed = URI.parse(uri) + + redirect_uris.any? do |redirect_uri| + redirect = URI.parse(redirect_uri) + + redirect.scheme == parsed.scheme && + redirect.host.in?(Oauth::LOOPBACK_HOSTS) && + parsed.host.in?(Oauth::LOOPBACK_HOSTS) && + redirect.path == parsed.path + end + rescue URI::InvalidURIError + false + end +end diff --git a/app/views/my/connected_apps/_connected_app.html.erb b/app/views/my/connected_apps/_connected_app.html.erb new file mode 100644 index 0000000000..33d63ce439 --- /dev/null +++ b/app/views/my/connected_apps/_connected_app.html.erb @@ -0,0 +1,13 @@ + + <%= client.name %> + <%= tokens.map { |t| t.permission.humanize }.uniq.sort.join(", ") %> + <%= local_datetime_tag tokens.map(&:created_at).min, style: :datetime %> + + <%= button_to my_connected_app_path(client), method: :delete, + class: "btn txt-negative btn--circle txt-x-small borderless fill-transparent", + data: { turbo_confirm: "Disconnect #{client.name}? This will revoke all access." } do %> + <%= icon_tag "trash" %> + Disconnect this app + <% end %> + + diff --git a/app/views/my/connected_apps/index.html.erb b/app/views/my/connected_apps/index.html.erb new file mode 100644 index 0000000000..75db574ba6 --- /dev/null +++ b/app/views/my/connected_apps/index.html.erb @@ -0,0 +1,32 @@ +<% @page_title = "Connected apps" %> + +<% content_for :header do %> +
+ <%= back_link_to "My profile", user_path(Current.user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> +
+ +

<%= @page_title %>

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

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

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

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

+ <% end %> +
diff --git a/app/views/oauth/authorizations/error.html.erb b/app/views/oauth/authorizations/error.html.erb new file mode 100644 index 0000000000..3493d9a7c2 --- /dev/null +++ b/app/views/oauth/authorizations/error.html.erb @@ -0,0 +1,15 @@ +
+
+

Authorization Error

+ +

+ <%= @error_description %> +

+ +

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

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

<%= @page_title %>

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

<%= @client.name %>

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

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

+ <% end %> +
+ +
+ <%= f.label :scope, "Permission" %> + <% scope_options = @client.scopes.include?("write") ? { "Read" => "read", "Read + Write" => "write" } : { "Read" => "read" } %> + <%= f.select :scope, options_for_select(scope_options, @scope), {}, class: "input input--select" %> +
+ +
+ <%= f.button type: :submit, class: "btn btn--link txt-medium" do %> + Authorize + <% end %> + + <%= f.button type: :submit, name: :error, value: "access_denied", class: "btn btn--outline txt-medium" do %> + Deny + <% end %> +
+ <% end %> +
diff --git a/app/views/users/_access_tokens.html.erb b/app/views/users/_access_tokens.html.erb index 285f87149e..36c6531162 100644 --- a/app/views/users/_access_tokens.html.erb +++ b/app/views/users/_access_tokens.html.erb @@ -1,4 +1,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 7cf5bdf7c1..2f3bfeda4b 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -52,6 +52,7 @@
<%= render "users/theme" %> <%= render "users/transfer", user: @user %> + <%= render "users/connected_apps" if Current.identity.access_tokens.oauth.exists? %> <%= render "users/access_tokens" %>
<% end %> diff --git a/config/routes.rb b/config/routes.rb index 97e34290cb..756cc41ad9 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 @@ -168,6 +178,7 @@ namespace :my do resource :identity, only: :show resources :access_tokens + resources :connected_apps, only: %i[ index destroy ] resources :pins resource :timezone resource :menu diff --git a/db/migrate/20251231163456_add_oauth.rb b/db/migrate/20251231163456_add_oauth.rb new file mode 100644 index 0000000000..74e6c36a26 --- /dev/null +++ b/db/migrate/20251231163456_add_oauth.rb @@ -0,0 +1,18 @@ +class AddOauth < ActiveRecord::Migration[8.2] + def change + create_table :oauth_clients, id: :uuid do |t| + t.string :client_id, null: false + t.string :name, null: false + t.json :redirect_uris + t.json :scopes + t.boolean :trusted, default: false + t.boolean :dynamically_registered, default: false + + t.timestamps + + t.index :client_id, unique: true + end + + add_reference :identity_access_tokens, :oauth_client, type: :uuid, foreign_key: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 86b9f29375..53cfc5da9d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_12_24_092315) do +ActiveRecord::Schema[8.2].define(version: 2025_12_31_163456) do create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -323,10 +323,12 @@ t.datetime "created_at", null: false t.text "description" t.uuid "identity_id", null: false + t.uuid "oauth_client_id" t.string "permission" t.string "token" t.datetime "updated_at", null: false t.index ["identity_id"], name: "index_access_token_on_identity_id" + t.index ["oauth_client_id"], name: "index_identity_access_tokens_on_oauth_client_id" end create_table "magic_links", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -385,6 +387,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 @@ -820,4 +834,6 @@ t.index ["account_id"], name: "index_webhooks_on_account_id" t.index ["board_id", "subscribed_actions"], name: "index_webhooks_on_board_id_and_subscribed_actions", length: { subscribed_actions: 255 } end + + add_foreign_key "identity_access_tokens", "oauth_clients" end diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index b76f998659..84af48794f 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_12_24_092315) do +ActiveRecord::Schema[8.2].define(version: 2025_12_31_163456) do create_table "accesses", id: :uuid, force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -323,10 +323,12 @@ t.datetime "created_at", null: false t.text "description", limit: 65535 t.uuid "identity_id", null: false + t.uuid "oauth_client_id" t.string "permission", limit: 255 t.string "token", limit: 255 t.datetime "updated_at", null: false t.index ["identity_id"], name: "index_access_token_on_identity_id" + t.index ["oauth_client_id"], name: "index_identity_access_tokens_on_oauth_client_id" end create_table "magic_links", id: :uuid, force: :cascade do |t| @@ -385,6 +387,18 @@ t.index ["user_id"], name: "index_notifications_on_user_id" end + create_table "oauth_clients", id: :uuid, force: :cascade do |t| + t.string "client_id", limit: 255, null: false + t.datetime "created_at", null: false + t.boolean "dynamically_registered", default: false + t.string "name", limit: 255, null: false + t.json "redirect_uris" + t.json "scopes" + t.boolean "trusted", default: false + t.datetime "updated_at", null: false + t.index ["client_id"], name: "index_oauth_clients_on_client_id", unique: true + end + create_table "pins", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.uuid "card_id", null: false diff --git a/test/controllers/my/connected_apps_controller_test.rb b/test/controllers/my/connected_apps_controller_test.rb new file mode 100644 index 0000000000..d67ba5b17c --- /dev/null +++ b/test/controllers/my/connected_apps_controller_test.rb @@ -0,0 +1,61 @@ +require "test_helper" + +class My::ConnectedAppsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :david + @identity = identities(:david) + end + + test "index shows connected OAuth apps" do + client = oauth_clients(:mcp_client) + @identity.access_tokens.create!(oauth_client: client, permission: :read) + + get my_connected_apps_path + assert_response :success + assert_match client.name, response.body + end + + test "index excludes PATs" do + # PAT has no oauth_client + @identity.access_tokens.create!(permission: :read, description: "My PAT") + + get my_connected_apps_path + assert_response :success + assert_no_match "My PAT", response.body + end + + test "destroy revokes all tokens for a client" do + client = oauth_clients(:mcp_client) + @identity.access_tokens.create!(oauth_client: client, permission: :read) + @identity.access_tokens.create!(oauth_client: client, permission: :write) + + assert_difference "Identity::AccessToken.count", -2 do + delete my_connected_app_path(client) + end + + assert_redirected_to my_connected_apps_path + assert_match "disconnected", flash[:notice] + end + + test "destroy only revokes tokens for the specified client" do + client1 = oauth_clients(:mcp_client) + client2 = Oauth::Client.create!(name: "Other App", redirect_uris: %w[ http://127.0.0.1/cb ]) + + @identity.access_tokens.create!(oauth_client: client1, permission: :read) + @identity.access_tokens.create!(oauth_client: client2, permission: :read) + + assert_difference "Identity::AccessToken.count", -1 do + delete my_connected_app_path(client1) + end + + assert @identity.access_tokens.exists?(oauth_client: client2) + end + + test "destroy returns 404 for unconnected client" do + client = oauth_clients(:mcp_client) + # No tokens for this client + + delete my_connected_app_path(client) + assert_response :not_found + end +end diff --git a/test/fixtures/oauth/clients.yml b/test/fixtures/oauth/clients.yml new file mode 100644 index 0000000000..86b6de7264 --- /dev/null +++ b/test/fixtures/oauth/clients.yml @@ -0,0 +1,21 @@ +mcp_client: + client_id: mcp_test_client_123 + name: Test MCP Client + redirect_uris: + - http://127.0.0.1:8888/callback + scopes: + - read + - write + dynamically_registered: true + trusted: false + +trusted_client: + client_id: trusted_client_456 + name: Trusted First-Party App + redirect_uris: + - https://app.example.com/oauth/callback + scopes: + - read + - write + dynamically_registered: false + trusted: true diff --git a/test/integration/oauth_flow_test.rb b/test/integration/oauth_flow_test.rb new file mode 100644 index 0000000000..b52d354163 --- /dev/null +++ b/test/integration/oauth_flow_test.rb @@ -0,0 +1,437 @@ +require "test_helper" + +class OauthFlowTest < ActionDispatch::IntegrationTest + # Authorization Endpoint + + test "authorization requires authentication" do + client = oauth_clients(:mcp_client) + + untenanted do + get new_oauth_authorization_path, params: { + client_id: client.client_id, + redirect_uri: "http://127.0.0.1:8888/callback", + response_type: "code", + code_challenge: "test_challenge", + code_challenge_method: "S256" + } + end + + assert_response :redirect + assert_match %r{/session/new}, response.location + end + + test "authorization shows consent screen" do + sign_in_as :david + client = oauth_clients(:mcp_client) + + untenanted do + get new_oauth_authorization_path, params: { + client_id: client.client_id, + redirect_uri: "http://127.0.0.1:8888/callback", + response_type: "code", + code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + code_challenge_method: "S256", + scope: "read", + state: "xyz123" + } + end + + assert_response :success + assert_select "form[action$=?]", "/oauth/authorization" + assert_match client.name, response.body + end + + test "authorization rejects invalid client_id" do + sign_in_as :david + + untenanted do + get new_oauth_authorization_path, params: { + client_id: "nonexistent", + redirect_uri: "http://127.0.0.1/cb", + response_type: "code", + code_challenge: "test", + code_challenge_method: "S256" + } + end + + assert_response :bad_request + assert_equal "invalid_request", response.parsed_body["error"] + end + + test "authorization rejects mismatched redirect_uri" do + sign_in_as :david + client = oauth_clients(:mcp_client) + + untenanted do + get new_oauth_authorization_path, params: { + client_id: client.client_id, + redirect_uri: "http://evil.com/steal", + response_type: "code", + code_challenge: "test", + code_challenge_method: "S256", + state: "abc" + } + end + + # Can't redirect to untrusted URI, so render HTML error page + assert_response :bad_request + assert_select "code", text: "invalid_request" + end + + test "authorization requires PKCE" do + sign_in_as :david + client = oauth_clients(:mcp_client) + + untenanted do + get new_oauth_authorization_path, params: { + client_id: client.client_id, + redirect_uri: "http://127.0.0.1:8888/callback", + response_type: "code", + state: "abc" + } + end + + # Per RFC 6749, redirect to client with error in query params + assert_response :redirect + redirect_params = CGI.parse(URI.parse(response.location).query) + assert_equal "invalid_request", redirect_params["error"].first + assert_match "code_challenge", redirect_params["error_description"].first + end + + test "authorization consent issues code" do + sign_in_as :david + client = oauth_clients(:mcp_client) + + untenanted do + post oauth_authorization_path, params: { + client_id: client.client_id, + redirect_uri: "http://127.0.0.1:8888/callback", + response_type: "code", + code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + code_challenge_method: "S256", + scope: "read", + state: "xyz123" + } + end + + assert_response :redirect + redirect_uri = URI.parse(response.location) + + assert_equal "127.0.0.1", redirect_uri.host + assert_equal "/callback", redirect_uri.path + + params = CGI.parse(redirect_uri.query) + assert_not_nil params["code"]&.first + assert_equal "xyz123", params["state"]&.first + end + + + # Token Endpoint + + test "token exchange with valid code and PKCE" do + code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + + client = oauth_clients(:mcp_client) + identity = identities(:david) + + code = Oauth::AuthorizationCode.generate \ + client_id: client.client_id, + identity_id: identity.id, + code_challenge: code_challenge, + redirect_uri: "http://127.0.0.1:8888/callback", + scope: "read" + + assert_difference "Identity::AccessToken.count", 1 do + untenanted do + post oauth_token_path, params: { + grant_type: "authorization_code", + code: code, + redirect_uri: "http://127.0.0.1:8888/callback", + code_verifier: code_verifier + }, as: :json + end + end + + assert_response :success + body = response.parsed_body + + assert_not_nil body["access_token"] + assert_equal "Bearer", body["token_type"] + assert_equal "read", body["scope"] + + token = Identity::AccessToken.find_by(token: body["access_token"]) + assert_equal client, token.oauth_client + assert_equal identity, token.identity + assert_equal "read", token.permission + end + + test "token exchange rejects invalid code" do + untenanted do + post oauth_token_path, params: { + grant_type: "authorization_code", + code: "invalid_code", + redirect_uri: "http://127.0.0.1/cb", + code_verifier: "verifier" + }, as: :json + end + + assert_response :bad_request + assert_equal "invalid_grant", response.parsed_body["error"] + end + + test "token exchange rejects wrong PKCE verifier" do + code_verifier = "correct_verifier_here" + code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + + client = oauth_clients(:mcp_client) + + code = Oauth::AuthorizationCode.generate \ + client_id: client.client_id, + identity_id: identities(:david).id, + code_challenge: code_challenge, + redirect_uri: "http://127.0.0.1:8888/callback", + scope: "read" + + untenanted do + post oauth_token_path, params: { + grant_type: "authorization_code", + code: code, + redirect_uri: "http://127.0.0.1:8888/callback", + code_verifier: "wrong_verifier" + }, as: :json + end + + assert_response :bad_request + assert_equal "invalid_grant", response.parsed_body["error"] + end + + test "token exchange rejects mismatched redirect_uri" do + code_verifier = "verifier" + code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + + client = oauth_clients(:mcp_client) + + code = Oauth::AuthorizationCode.generate \ + client_id: client.client_id, + identity_id: identities(:david).id, + code_challenge: code_challenge, + redirect_uri: "http://127.0.0.1:8888/callback", + scope: "read" + + untenanted do + post oauth_token_path, params: { + grant_type: "authorization_code", + code: code, + redirect_uri: "http://127.0.0.1:9999/different", + code_verifier: code_verifier + }, as: :json + end + + assert_response :bad_request + assert_equal "invalid_grant", response.parsed_body["error"] + end + + test "token exchange rejects expired code" do + code_verifier = "verifier" + code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + + client = oauth_clients(:mcp_client) + + code = Oauth::AuthorizationCode.generate \ + client_id: client.client_id, + identity_id: identities(:david).id, + code_challenge: code_challenge, + redirect_uri: "http://127.0.0.1:8888/callback", + scope: "read" + + travel 65.seconds do + untenanted do + post oauth_token_path, params: { + grant_type: "authorization_code", + code: code, + redirect_uri: "http://127.0.0.1:8888/callback", + code_verifier: code_verifier + }, as: :json + end + end + + assert_response :bad_request + assert_equal "invalid_grant", response.parsed_body["error"] + end + + test "token exchange rejects unsupported grant type" do + untenanted do + post oauth_token_path, params: { grant_type: "client_credentials" }, as: :json + end + + assert_response :bad_request + assert_equal "unsupported_grant_type", response.parsed_body["error"] + end + + + # Token Revocation (RFC 7009) + + test "revocation deletes access token" do + token = identity_access_tokens(:davids_api_token) + + assert_difference "Identity::AccessToken.count", -1 do + untenanted do + post oauth_revocation_path, params: { token: token.token }, as: :json + end + end + + assert_response :success + end + + test "revocation returns 200 for nonexistent token" do + untenanted do + post oauth_revocation_path, params: { token: "nonexistent_token" }, as: :json + end + assert_response :success + end + + test "revocation returns 400 for blank token" do + untenanted do + post oauth_revocation_path, params: { token: "" }, as: :json + end + assert_response :bad_request + end + + + # Discovery Metadata (RFC 8414) + + test "authorization server metadata includes required fields" do + untenanted do + get "/.well-known/oauth-authorization-server" + end + + assert_response :success + body = response.parsed_body + + assert_equal "http://www.example.com/", body["issuer"] + assert_match %r{/oauth/authorization/new$}, body["authorization_endpoint"] + assert_match %r{/oauth/token$}, body["token_endpoint"] + assert_match %r{/oauth/clients$}, body["registration_endpoint"] + assert_includes body["response_types_supported"], "code" + assert_includes body["code_challenge_methods_supported"], "S256" + end + + test "protected resource metadata includes authorization server" do + untenanted do + get "/.well-known/oauth-protected-resource" + end + + assert_response :success + body = response.parsed_body + + assert_equal "http://www.example.com/", body["resource"] + assert_includes body["authorization_servers"], "http://www.example.com/" + end + + + # Dynamic Client Registration (RFC 7591) + + test "DCR creates client with loopback redirect" do + assert_difference "Oauth::Client.count", 1 do + untenanted do + post oauth_clients_path, params: { + client_name: "Test MCP Client", + redirect_uris: [ "http://127.0.0.1:8888/callback" ] + }, as: :json + end + end + + assert_response :created + body = response.parsed_body + + assert_not_nil body["client_id"] + assert_equal "Test MCP Client", body["client_name"] + assert_equal [ "http://127.0.0.1:8888/callback" ], body["redirect_uris"] + end + + test "DCR rejects non-loopback redirect" do + assert_no_difference "Oauth::Client.count" do + untenanted do + post oauth_clients_path, params: { + client_name: "Evil Client", + redirect_uris: [ "https://evil.com/steal" ] + }, as: :json + end + end + + assert_response :bad_request + assert_equal "invalid_redirect_uri", response.parsed_body["error"] + end + + test "DCR requires redirect_uris" do + untenanted do + post oauth_clients_path, params: { client_name: "No Redirect" }, as: :json + end + + assert_response :bad_request + assert_equal "invalid_client_metadata", response.parsed_body["error"] + end + + + # Full OAuth Flow + + test "complete authorization code flow" do + sign_in_as :david + client = oauth_clients(:mcp_client) + code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + + # Step 1: Get consent screen + untenanted do + get new_oauth_authorization_path, params: { + client_id: client.client_id, + redirect_uri: "http://127.0.0.1:8888/callback", + response_type: "code", + code_challenge: code_challenge, + code_challenge_method: "S256", + scope: "read", + state: "test_state" + } + end + assert_response :success + + # Step 2: Grant consent + untenanted do + post oauth_authorization_path, params: { + client_id: client.client_id, + redirect_uri: "http://127.0.0.1:8888/callback", + response_type: "code", + code_challenge: code_challenge, + code_challenge_method: "S256", + scope: "read", + state: "test_state" + } + end + assert_response :redirect + + # Extract code from redirect + redirect_uri = URI.parse(response.location) + params = CGI.parse(redirect_uri.query) + code = params["code"].first + assert_not_nil code + assert_equal "test_state", params["state"].first + + # Step 3: Exchange code for token + assert_difference "Identity::AccessToken.count", 1 do + untenanted do + post oauth_token_path, params: { + grant_type: "authorization_code", + code: code, + redirect_uri: "http://127.0.0.1:8888/callback", + code_verifier: code_verifier + }, as: :json + end + end + + assert_response :success + body = response.parsed_body + assert_not_nil body["access_token"] + assert_equal "Bearer", body["token_type"] + end +end diff --git a/test/models/oauth/authorization_code_test.rb b/test/models/oauth/authorization_code_test.rb new file mode 100644 index 0000000000..67610bcee2 --- /dev/null +++ b/test/models/oauth/authorization_code_test.rb @@ -0,0 +1,137 @@ +require "test_helper" + +class Oauth::AuthorizationCodeTest < ActiveSupport::TestCase + test "generate creates encrypted code" do + code = Oauth::AuthorizationCode.generate \ + client_id: "test_client", + identity_id: 123, + code_challenge: "abc123", + redirect_uri: "http://127.0.0.1:8888/callback", + scope: "read" + + assert_kind_of String, code + assert code.length > 50, "Encrypted code should be reasonably long" + end + + test "parse decrypts valid code" do + code = Oauth::AuthorizationCode.generate \ + client_id: "test_client", + identity_id: 456, + code_challenge: "challenge_hash", + redirect_uri: "http://127.0.0.1:8888/callback", + scope: "read write" + + parsed = Oauth::AuthorizationCode.parse(code) + + assert_not_nil parsed + assert_equal "test_client", parsed.client_id + assert_equal 456, parsed.identity_id + assert_equal "challenge_hash", parsed.code_challenge + assert_equal "http://127.0.0.1:8888/callback", parsed.redirect_uri + assert_equal "read write", parsed.scope + end + + test "parse returns nil for blank code" do + assert_nil Oauth::AuthorizationCode.parse("") + assert_nil Oauth::AuthorizationCode.parse(nil) + end + + test "parse returns nil for invalid code" do + assert_nil Oauth::AuthorizationCode.parse("garbage_data_here") + end + + test "parse returns nil for tampered code" do + code = Oauth::AuthorizationCode.generate \ + client_id: "test_client", + identity_id: 123, + code_challenge: "abc", + redirect_uri: "http://127.0.0.1/cb", + scope: "read" + + tampered = code[0...-10] + "XXXXXXXXXX" + assert_nil Oauth::AuthorizationCode.parse(tampered) + end + + test "parse returns nil for expired code" do + code = Oauth::AuthorizationCode.generate \ + client_id: "test_client", + identity_id: 123, + code_challenge: "abc", + redirect_uri: "http://127.0.0.1/cb", + scope: "read" + + travel 65.seconds do + assert_nil Oauth::AuthorizationCode.parse(code) + end + end + + test "code is valid within 60 second window" do + code = Oauth::AuthorizationCode.generate \ + client_id: "test_client", + identity_id: 123, + code_challenge: "abc", + redirect_uri: "http://127.0.0.1/cb", + scope: "read" + + travel 55.seconds do + parsed = Oauth::AuthorizationCode.parse(code) + assert_not_nil parsed + assert_equal "test_client", parsed.client_id + end + end + + test "valid_pkce? returns true for correct S256 verifier" do + code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + + details = Oauth::AuthorizationCode::Details.new \ + client_id: "test", + identity_id: 1, + code_challenge: code_challenge, + redirect_uri: "http://127.0.0.1/cb", + scope: "read" + + assert Oauth::AuthorizationCode.valid_pkce?(details, code_verifier) + end + + test "valid_pkce? returns false for wrong verifier" do + code_verifier = "correct_verifier" + code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + + details = Oauth::AuthorizationCode::Details.new \ + client_id: "test", + identity_id: 1, + code_challenge: code_challenge, + redirect_uri: "http://127.0.0.1/cb", + scope: "read" + + assert_not Oauth::AuthorizationCode.valid_pkce?(details, "wrong_verifier") + end + + test "valid_pkce? returns false for nil code details" do + assert_not Oauth::AuthorizationCode.valid_pkce?(nil, "verifier") + end + + test "valid_pkce? returns false for blank verifier" do + details = Oauth::AuthorizationCode::Details.new \ + client_id: "test", + identity_id: 1, + code_challenge: "challenge", + redirect_uri: "http://127.0.0.1/cb", + scope: "read" + + assert_not Oauth::AuthorizationCode.valid_pkce?(details, "") + assert_not Oauth::AuthorizationCode.valid_pkce?(details, nil) + end + + test "auth code details are immutable" do + details = Oauth::AuthorizationCode::Details.new \ + client_id: "test", + identity_id: 1, + code_challenge: "challenge", + redirect_uri: "http://127.0.0.1/cb", + scope: "read" + + assert_raises(FrozenError) { details.instance_variable_set(:@client_id, "hacked") } + end +end diff --git a/test/models/oauth/client_test.rb b/test/models/oauth/client_test.rb new file mode 100644 index 0000000000..55108b564e --- /dev/null +++ b/test/models/oauth/client_test.rb @@ -0,0 +1,137 @@ +require "test_helper" + +class Oauth::ClientTest < ActiveSupport::TestCase + test "generates client_id on create" do + client = Oauth::Client.create!(name: "Test", redirect_uris: %w[ http://127.0.0.1:8888/callback ]) + assert_equal 32, client.client_id.length + assert_match(/\A[a-zA-Z0-9]+\z/, client.client_id) + end + + test "client_id must be unique" do + existing = oauth_clients(:mcp_client) + client = Oauth::Client.new(name: "Dupe", client_id: existing.client_id, redirect_uris: %w[ http://127.0.0.1/cb ]) + assert_not client.valid? + assert_includes client.errors[:client_id], "has already been taken" + end + + test "name is required" do + client = Oauth::Client.new(redirect_uris: %w[ http://127.0.0.1/cb ]) + assert_not client.valid? + assert_includes client.errors[:name], "can't be blank" + end + + test "redirect_uris required" do + client = Oauth::Client.new(name: "Test") + assert_not client.valid? + assert_includes client.errors[:redirect_uris], "can't be blank" + end + + test "dynamically registered clients must use http loopback URIs" do + client = Oauth::Client.new( + name: "External", + redirect_uris: %w[ https://evil.com/callback ], + dynamically_registered: true + ) + assert_not client.valid? + assert_includes client.errors[:redirect_uris], "must be a local loopback URI for dynamically registered clients" + end + + test "dynamically registered clients reject https loopback" do + client = Oauth::Client.new( + name: "HTTPS Loopback", + redirect_uris: %w[ https://127.0.0.1:8888/callback ], + dynamically_registered: true + ) + assert_not client.valid? + assert_includes client.errors[:redirect_uris], "must be a local loopback URI for dynamically registered clients" + end + + test "redirect URIs must not contain fragments" do + client = Oauth::Client.new( + name: "Fragment", + redirect_uris: %w[ http://127.0.0.1:8888/callback#section ], + dynamically_registered: true + ) + assert_not client.valid? + assert_includes client.errors[:redirect_uris], "must not contain fragments" + end + + test "dynamically registered clients can use 127.0.0.1" do + client = Oauth::Client.new( + name: "Loopback", + redirect_uris: %w[ http://127.0.0.1:9999/callback ], + dynamically_registered: true + ) + assert client.valid? + end + + test "dynamically registered clients can use localhost" do + client = Oauth::Client.new( + name: "Localhost", + redirect_uris: %w[ http://localhost:9999/callback ], + dynamically_registered: true + ) + assert client.valid? + end + + test "dynamically registered clients can use IPv6 loopback" do + client = Oauth::Client.new( + name: "IPv6", + redirect_uris: %w[ http://[::1]:9999/callback ], + dynamically_registered: true + ) + assert client.valid? + end + + test "loopback? returns true for loopback-only clients" do + client = Oauth::Client.new(redirect_uris: %w[ http://127.0.0.1:8888/cb http://localhost:9999/cb ]) + assert client.loopback? + end + + test "loopback? returns false for non-loopback clients" do + client = Oauth::Client.new(redirect_uris: %w[ https://example.com/cb ]) + assert_not client.loopback? + end + + test "allows_redirect? matches exact URI" do + client = Oauth::Client.new(redirect_uris: %w[ http://127.0.0.1:8888/callback ]) + assert client.allows_redirect?("http://127.0.0.1:8888/callback") + assert_not client.allows_redirect?("http://127.0.0.1:8888/other") + end + + test "allows_redirect? allows different ports for loopback clients" do + client = Oauth::Client.new(redirect_uris: %w[ http://127.0.0.1:8888/callback ]) + assert client.allows_redirect?("http://127.0.0.1:9999/callback") + assert client.allows_redirect?("http://localhost:7777/callback") + end + + test "allows_redirect? requires matching path for loopback flexibility" do + client = Oauth::Client.new(redirect_uris: %w[ http://127.0.0.1:8888/callback ]) + assert_not client.allows_redirect?("http://127.0.0.1:9999/other") + end + + test "allows_scope? checks client scopes" do + client = Oauth::Client.new(scopes: %w[ read write ]) + assert client.allows_scope?("read") + assert client.allows_scope?("write") + assert client.allows_scope?("read write") + assert_not client.allows_scope?("admin") + assert_not client.allows_scope?("read admin") + assert_not client.allows_scope?("") + end + + test "default scopes are set" do + client = Oauth::Client.new(name: "Test", redirect_uris: %w[ http://127.0.0.1/cb ]) + assert_equal %w[ read ], client.scopes + end + + test "trusted scope" do + trusted = Oauth::Client.trusted + assert trusted.all?(&:trusted?) + end + + test "dynamically_registered scope" do + dcr_clients = Oauth::Client.dynamically_registered + assert dcr_clients.all?(&:dynamically_registered?) + end +end