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 @@
+
+
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