diff --git a/OAUTH_NEXT.md b/OAUTH_NEXT.md
new file mode 100644
index 0000000000..856738296a
--- /dev/null
+++ b/OAUTH_NEXT.md
@@ -0,0 +1,199 @@
+# Fizzy OAuth 2.1 + MCP
+
+OAuth for Fizzy. One table, one column, a handful of small controllers. MCP support included.
+
+---
+
+## The Insight
+
+Fizzy's `Identity::AccessToken` is already perfect:
+
+```ruby
+class Identity::AccessToken < ApplicationRecord
+ belongs_to :identity
+ has_secure_token
+ enum :permission, %w[ read write ].index_by(&:itself), default: :read
+
+ def allows?(method)
+ method.in?(%w[ GET HEAD ]) || write?
+ end
+end
+```
+
+**10 lines.** Don't replace it. Extend it.
+
+---
+
+## What We Add
+
+| Addition | Type | Purpose |
+|----------|------|---------|
+| `oauth_clients` | table | Client registry (MCP DCR, first-party) |
+| `oauth_client_id` | column | Links access tokens to OAuth clients |
+
+That's it. One table. One column.
+
+- **PATs stay PATs** — tokens with `oauth_client_id = nil`
+- **OAuth tokens are PATs with a client** — `oauth_client_id` is set
+- **Bearer auth works unchanged** — the `Authentication` concern already uses `Identity::AccessToken`
+
+---
+
+## Authorization Codes: Stateless
+
+No table. Rails primitives only.
+
+```ruby
+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:)
+ encryptor.encrypt_and_sign(
+ { c: client_id, i: identity_id, h: code_challenge, r: redirect_uri, s: scope },
+ expires_in: 60.seconds
+ )
+ end
+
+ def parse(code)
+ return nil if code.blank?
+ data = encryptor.decrypt_and_verify(code)
+ return nil if data.nil?
+ Details.new(
+ client_id: data["c"],
+ identity_id: data["i"],
+ code_challenge: data["h"],
+ redirect_uri: data["r"],
+ scope: data["s"]
+ )
+ rescue ActiveSupport::MessageEncryptor::InvalidMessage, ActiveSupport::MessageVerifier::InvalidSignature
+ nil
+ end
+
+ def valid_pkce?(code_data, code_verifier)
+ return false if code_data.nil? || code_verifier.blank?
+ expected = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
+ ActiveSupport::SecurityUtils.secure_compare(expected, code_data.code_challenge)
+ end
+
+ private
+ def encryptor
+ @encryptor ||= ActiveSupport::MessageEncryptor.new(
+ Rails.application.key_generator.generate_key("oauth/authorization_codes", 32)
+ )
+ end
+ end
+end
+```
+
+- 60-second TTL + PKCE-bound
+- No database = no cleanup job
+
+---
+
+## Grants: Implicit
+
+No `oauth_authorizations` table.
+
+A "grant" is just "a token exists for this client + identity." Revocation = delete tokens.
+
+"Connected Apps" UI at `/my/connected_apps`:
+
+```ruby
+# List apps
+current_identity.access_tokens.where.not(oauth_client: nil).includes(:oauth_client).group_by(&:oauth_client)
+
+# Disconnect an app (revoke all tokens for that client)
+current_identity.access_tokens.where(oauth_client: client).destroy_all
+```
+
+---
+
+## Scopes
+
+OAuth scopes are space-delimited (e.g., `"read write"`). We map to `Identity::AccessToken#permission`:
+
+- If `"write"` is in the scope list → `permission: "write"`
+- Otherwise → `permission: "read"`
+
+The token response returns the granted scopes as a space-delimited string.
+
+---
+
+## Token Lifetime
+
+Access tokens **do not expire**. This matches PAT behavior and keeps the implementation simple:
+
+- No refresh tokens needed
+- No background jobs to clean up expired tokens
+- Revocation is explicit: via `/oauth/revocation` endpoint or "Connected Apps" UI
+
+If expiration is needed later, add an `expires_at` column to `identity_access_tokens` and return `expires_in` in the token response. The revocation endpoint already handles cleanup.
+
+---
+
+## Routes
+
+```ruby
+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
+ resources :clients, only: :create # POST /oauth/clients (DCR)
+ resource :authorization, only: %i[ new create ] # GET/POST /oauth/authorization
+ resource :token, only: :create # POST /oauth/token
+ resource :revocation, only: :create # POST /oauth/revocation
+end
+```
+
+Two well-known endpoints for discovery. Singular resources for OAuth protocol endpoints. Plural for the client registry.
+
+---
+
+## Redirect URI Matching
+
+Per RFC 8252, loopback clients get port flexibility:
+
+- Registered: `http://127.0.0.1:8888/callback`
+- Allowed: `http://127.0.0.1:9999/callback` (different port, same path)
+- Allowed: `http://localhost:7777/callback` (different loopback host)
+
+Non-loopback clients require exact string match.
+
+DCR clients are restricted to loopback URIs only (http, not https).
+
+---
+
+## Security
+
+- **Short-lived, PKCE-bound codes**: 60 seconds, S256 only
+- **Loopback-only DCR**: MCP clients must use `127.0.0.1`, `localhost`, or `[::1]`
+- **PKCE required**: no "plain" method
+- **Port-flexible loopback matching**: per RFC 8252
+- **Rate limited**: DCR (10/min), token exchange (20/min)
+
+---
+
+## Standards
+
+- RFC 6749 (OAuth 2.0)
+- RFC 6750 (Bearer tokens)
+- RFC 7636 (PKCE, S256 only)
+- RFC 7591 (DCR subset)
+- RFC 8414 (AS Discovery)
+- RFC 8252 (Loopback redirects)
+- RFC 9728 (Protected Resource Metadata)
+
+---
+
+## Why This Over "Proper OAuth"
+
+| "Proper" OAuth | This |
+|----------------|------|
+| 4 tables | 1 table + 1 column |
+| Migrate PATs | PATs stay |
+| Stored auth codes | Stateless |
+| Explicit grant table | Implicit |
+| ~600 lines | ~350 lines |
+
+Both are correct. This one is half the code.
diff --git a/app/controllers/mcp_controller.rb b/app/controllers/mcp_controller.rb
new file mode 100644
index 0000000000..621a35c6cb
--- /dev/null
+++ b/app/controllers/mcp_controller.rb
@@ -0,0 +1,118 @@
+class McpController < ApplicationController
+ include Mcp::Protocol
+
+ disallow_account_scope
+ allow_unauthenticated_access
+ before_action :require_bearer_token, only: :create
+
+ def discovery
+ render json: {
+ name: "Fizzy",
+ description: "Kanban workflow management",
+ mcp_version: Mcp::PROTOCOL_VERSION,
+ capabilities: { tools: {}, resources: {} },
+ oauth: { server: oauth_authorization_server_url }
+ }
+ end
+
+ def create
+ case jsonrpc_method
+ when "initialize" then handle_initialize
+ when "tools/list" then handle_tools_list
+ when "tools/call" then handle_tools_call
+ when "resources/list" then handle_resources_list
+ when "resources/read" then handle_resources_read
+ else
+ jsonrpc_error :method_not_found
+ end
+ rescue ActiveRecord::RecordNotFound => e
+ jsonrpc_error :invalid_params, "Record not found: #{e.message}"
+ rescue ActiveRecord::RecordInvalid => e
+ jsonrpc_error :invalid_params, e.message
+ rescue ArgumentError => e
+ jsonrpc_error :invalid_params, e.message
+ end
+
+ private
+ def handle_initialize
+ client_version = jsonrpc_params[:protocolVersion]
+
+ negotiated_version = if Mcp::SUPPORTED_VERSIONS.include?(client_version)
+ client_version
+ else
+ Mcp::PROTOCOL_VERSION
+ end
+
+ jsonrpc_response({
+ protocolVersion: negotiated_version,
+ capabilities: { tools: {}, resources: {} },
+ serverInfo: { name: "Fizzy", title: "Fizzy Kanban", version: "1.0.0" }
+ })
+ end
+
+ def handle_tools_list
+ jsonrpc_response Mcp::Tools.list
+ end
+
+ def handle_tools_call
+ name = jsonrpc_params[:name]
+ arguments = jsonrpc_params[:arguments]&.permit!&.to_h || {}
+
+ result = Mcp::Tools.call(name, arguments, identity: Current.identity)
+ jsonrpc_response result
+ end
+
+ def handle_resources_list
+ jsonrpc_response Mcp::Resources.list
+ end
+
+ def handle_resources_read
+ uri = jsonrpc_params[:uri]
+ result = Mcp::Resources.read(uri, identity: Current.identity)
+ jsonrpc_response result
+ end
+
+ def require_bearer_token
+ if token = request.authorization.to_s[/\ABearer (.+)\z/i, 1]
+ if access_token = Identity::AccessToken.find_by(token: token)
+ if access_token.allows_operation?(mcp_operation)
+ Current.identity = access_token.identity
+ return
+ else
+ response.headers["WWW-Authenticate"] = %(Bearer error="insufficient_scope")
+ head :forbidden and return
+ end
+ end
+ end
+
+ response.headers["WWW-Authenticate"] = %(Bearer resource_metadata="#{oauth_protected_resource_url}")
+ head :unauthorized
+ end
+
+ def mcp_operation
+ case jsonrpc_method
+ when "tools/call" then :write
+ else :read
+ end
+ end
+
+ def oauth_protected_resource_url
+ Rails.application.routes.url_helpers.url_for \
+ controller: "oauth/protected_resource_metadata",
+ action: "show",
+ only_path: false,
+ host: request.host,
+ port: request.port,
+ protocol: request.protocol
+ end
+
+ def oauth_authorization_server_url
+ Rails.application.routes.url_helpers.url_for \
+ controller: "oauth/metadata",
+ action: "show",
+ only_path: false,
+ host: request.host,
+ port: request.port,
+ protocol: request.protocol
+ end
+end
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..53f10da898
--- /dev/null
+++ b/app/controllers/oauth/authorizations_controller.rb
@@ -0,0 +1,122 @@
+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
+ before_action :allow_oauth_redirect_in_csp
+
+ def new
+ # Normalize scope: if "write" is requested, use "write" (which implies read)
+ requested_scopes = params[:scope].to_s.split
+ @scope = requested_scopes.include?("write") ? "write" : "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
+
+ # Safari blocks form submission redirects to URLs not in form-action CSP.
+ # Add the validated redirect_uri to allow the OAuth callback redirect.
+ def allow_oauth_redirect_in_csp
+ return unless params[:redirect_uri].present?
+
+ redirect_origin = URI.parse(params[:redirect_uri]).then { "#{_1.scheme}://#{_1.host}:#{_1.port}" }
+ request.content_security_policy.form_action :self, redirect_origin
+ rescue URI::InvalidURIError
+ # Invalid URI will be caught by validate_redirect_uri
+ 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..bbbfefb101 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
@@ -7,4 +11,8 @@ class Identity::AccessToken < ApplicationRecord
def allows?(method)
method.in?(%w[ GET HEAD ]) || write?
end
+
+ def allows_operation?(operation)
+ operation == :read || write?
+ end
end
diff --git a/app/models/mcp.rb b/app/models/mcp.rb
new file mode 100644
index 0000000000..397e138a34
--- /dev/null
+++ b/app/models/mcp.rb
@@ -0,0 +1,4 @@
+module Mcp
+ PROTOCOL_VERSION = "2025-06-18"
+ SUPPORTED_VERSIONS = %w[ 2025-06-18 2025-03-26 ].freeze
+end
diff --git a/app/models/mcp/protocol.rb b/app/models/mcp/protocol.rb
new file mode 100644
index 0000000000..42e6872a00
--- /dev/null
+++ b/app/models/mcp/protocol.rb
@@ -0,0 +1,77 @@
+module Mcp::Protocol
+ extend ActiveSupport::Concern
+
+ JSONRPC_VERSION = "2.0"
+
+ included do
+ before_action :parse_jsonrpc_request, only: :create
+ before_action :validate_protocol_version, only: :create
+ end
+
+ private
+ attr_reader :jsonrpc_id, :jsonrpc_method, :jsonrpc_params, :protocol_version
+
+ def notification?
+ !params.key?(:id)
+ end
+
+ def parse_jsonrpc_request
+ @jsonrpc_id = params[:id]
+ @jsonrpc_method = params[:method]
+ @jsonrpc_params = params[:params] || {}
+ end
+
+ def validate_protocol_version
+ # Initialize doesn't require version header (it's where version is negotiated)
+ return if jsonrpc_method == "initialize"
+
+ version = request.headers["MCP-Protocol-Version"]
+
+ # Per spec: if no header and no other way to identify, assume 2025-03-26
+ @protocol_version = version.presence || "2025-03-26"
+
+ unless Mcp::SUPPORTED_VERSIONS.include?(@protocol_version)
+ jsonrpc_error :invalid_request, "Unsupported protocol version: #{@protocol_version}"
+ end
+ end
+
+ def jsonrpc_response(result)
+ # JSON-RPC 2.0: Notifications don't receive responses
+ return head(:accepted) if notification?
+
+ render json: {
+ jsonrpc: JSONRPC_VERSION,
+ id: jsonrpc_id,
+ result: result
+ }
+ end
+
+ def jsonrpc_error(code, message = nil, data: nil)
+ # JSON-RPC 2.0: Notifications don't receive responses (even errors)
+ return head(:accepted) if notification?
+ error = case code
+ when :parse_error then { code: -32700, message: message || "Parse error" }
+ when :invalid_request then { code: -32600, message: message || "Invalid request" }
+ when :method_not_found then { code: -32601, message: message || "Method not found" }
+ when :invalid_params then { code: -32602, message: message || "Invalid params" }
+ when :internal_error then { code: -32603, message: message || "Internal error" }
+ else { code: code, message: message }
+ end
+
+ error[:data] = data if data.present?
+
+ render json: {
+ jsonrpc: JSONRPC_VERSION,
+ id: jsonrpc_id,
+ error: error
+ }, status: error_status(code)
+ end
+
+ def error_status(code)
+ case code
+ when :parse_error, :invalid_request, :invalid_params then :bad_request
+ when :method_not_found then :not_found
+ else :internal_server_error
+ end
+ end
+end
diff --git a/app/models/mcp/resources.rb b/app/models/mcp/resources.rb
new file mode 100644
index 0000000000..9d716185f4
--- /dev/null
+++ b/app/models/mcp/resources.rb
@@ -0,0 +1,186 @@
+module Mcp::Resources
+ extend self
+
+ RESOURCES = [
+ {
+ uri: "fizzy://accounts",
+ name: "accounts",
+ title: "Available Accounts",
+ description: "List of accounts accessible to the authenticated identity",
+ mimeType: "application/json"
+ },
+ {
+ uriTemplate: "fizzy://accounts/{account_id}/overview",
+ name: "overview",
+ title: "Workspace Overview",
+ description: "Summary of boards and recent activity for an account",
+ mimeType: "application/json"
+ },
+ {
+ uriTemplate: "fizzy://accounts/{account_id}/boards/{id}",
+ name: "board",
+ title: "Board Details",
+ description: "Board with columns and cards summary",
+ mimeType: "application/json"
+ },
+ {
+ uriTemplate: "fizzy://accounts/{account_id}/cards/{number}",
+ name: "card",
+ title: "Card Details",
+ description: "Full card with comments and steps",
+ mimeType: "application/json"
+ }
+ ]
+
+ def list
+ { resources: RESOURCES }
+ end
+
+ def read(uri, identity:)
+ case uri
+ when "fizzy://accounts"
+ accounts(identity)
+ when %r{\Afizzy://accounts/([^/]+)/overview\z}
+ with_account($1, identity) { overview }
+ when %r{\Afizzy://accounts/([^/]+)/boards/(.+)\z}
+ with_account($1, identity) { board($2) }
+ when %r{\Afizzy://accounts/([^/]+)/cards/(\d+)\z}
+ with_account($1, identity) { card($2) }
+ else
+ raise ArgumentError, "Unknown resource: #{uri}"
+ end
+ end
+
+ private
+ def with_account(account_id, identity)
+ account = identity.accounts.find_by!(id: account_id)
+ user = identity.users.find_by!(account: account)
+
+ Current.account = account
+ Current.user = user
+
+ yield
+ rescue ActiveRecord::RecordNotFound
+ raise ArgumentError, "Account not found or not accessible: #{account_id}"
+ end
+
+ def accounts(identity)
+ {
+ contents: [ {
+ uri: "fizzy://accounts",
+ mimeType: "application/json",
+ text: {
+ accounts: identity.accounts.map { |a|
+ { id: a.id, name: a.name }
+ }
+ }.to_json
+ } ]
+ }
+ end
+
+ def overview
+ {
+ contents: [ {
+ uri: "fizzy://accounts/#{Current.account.id}/overview",
+ mimeType: "application/json",
+ text: overview_content.to_json
+ } ]
+ }
+ end
+
+ def overview_content
+ {
+ account: { id: Current.account.id, name: Current.account.name },
+ boards: Current.user.boards.includes(:columns).map { |b|
+ {
+ id: b.id,
+ name: b.name,
+ columns: b.columns.sorted.map(&:name),
+ card_count: b.cards.count
+ }
+ },
+ in_progress: in_progress_cards,
+ recent_activity: recent_activity
+ }
+ end
+
+ def in_progress_cards
+ Current.user.accessible_cards
+ .joins(:column)
+ .where(columns: { name: [ "In Progress", "In progress", "Doing", "Active" ] })
+ .limit(10)
+ .map { |c| card_summary(c) }
+ end
+
+ def recent_activity
+ Current.user.accessible_cards
+ .order(updated_at: :desc)
+ .limit(5)
+ .map { |c| card_summary(c) }
+ end
+
+ def board(id)
+ board = Current.user.boards.includes(:columns).find(id)
+
+ {
+ contents: [ {
+ uri: "fizzy://accounts/#{Current.account.id}/boards/#{id}",
+ mimeType: "application/json",
+ text: board_content(board).to_json
+ } ]
+ }
+ end
+
+ def board_content(board)
+ {
+ id: board.id,
+ name: board.name,
+ columns: board.columns.sorted.map { |col|
+ {
+ id: col.id,
+ name: col.name,
+ card_count: col.cards.count,
+ cards: col.cards.limit(5).map { |c| card_summary(c) }
+ }
+ }
+ }
+ end
+
+ def card(number)
+ card = Current.user.accessible_cards.find_by!(number: number)
+
+ {
+ contents: [ {
+ uri: "fizzy://accounts/#{Current.account.id}/cards/#{number}",
+ mimeType: "application/json",
+ text: card_content(card).to_json
+ } ]
+ }
+ end
+
+ def card_content(card)
+ {
+ number: card.number,
+ title: card.title,
+ description: card.description&.to_plain_text,
+ board: card.board.name,
+ column: card.column&.name,
+ created_at: card.created_at.iso8601,
+ updated_at: card.updated_at.iso8601,
+ assignees: card.assignees.map(&:name),
+ steps: card.steps.map { |s| { text: s.description, done: s.checked? } },
+ comments: card.comments.limit(20).map { |c|
+ { author: c.creator.name, text: c.body.to_plain_text, at: c.created_at.iso8601 }
+ }
+ }
+ end
+
+ def card_summary(card)
+ {
+ number: card.number,
+ title: card.title,
+ board: card.board.name,
+ column: card.column&.name
+ }
+ end
+end
diff --git a/app/models/mcp/tools.rb b/app/models/mcp/tools.rb
new file mode 100644
index 0000000000..4b1352f1df
--- /dev/null
+++ b/app/models/mcp/tools.rb
@@ -0,0 +1,226 @@
+module Mcp::Tools
+ extend self
+
+ TOOLS = [
+ {
+ name: "create_board",
+ title: "Create Board",
+ description: "Create a new board for organizing work",
+ inputSchema: {
+ type: "object",
+ properties: {
+ account: { type: "string", description: "Account ID (required)" },
+ name: { type: "string", description: "Board name" },
+ columns: { type: "array", items: { type: "string" }, description: "Column names (default: Backlog, In Progress, Done)" }
+ },
+ required: %w[ account name ]
+ },
+ annotations: {
+ readOnlyHint: false,
+ destructiveHint: false,
+ idempotentHint: false,
+ openWorldHint: false
+ }
+ },
+ {
+ name: "create_card",
+ title: "Create Card",
+ description: "Create a new card on a board",
+ inputSchema: {
+ type: "object",
+ properties: {
+ account: { type: "string", description: "Account ID (required)" },
+ title: { type: "string", description: "What needs to be done" },
+ board: { type: "string", description: "Board name or ID (optional, uses most recent)" },
+ description: { type: "string", description: "Details, context, acceptance criteria" }
+ },
+ required: %w[ account title ]
+ },
+ annotations: {
+ readOnlyHint: false,
+ destructiveHint: false,
+ idempotentHint: false,
+ openWorldHint: false
+ }
+ },
+ {
+ name: "update_card",
+ title: "Update Card",
+ description: "Update a card or add a comment",
+ inputSchema: {
+ type: "object",
+ properties: {
+ account: { type: "string", description: "Account ID (required)" },
+ card: { type: "string", description: "Card number (e.g. '123' or '#123')" },
+ title: { type: "string", description: "New title" },
+ description: { type: "string", description: "New description" },
+ comment: { type: "string", description: "Add a comment to the card" }
+ },
+ required: %w[ account card ]
+ },
+ annotations: {
+ readOnlyHint: false,
+ destructiveHint: false,
+ idempotentHint: false,
+ openWorldHint: false
+ }
+ },
+ {
+ name: "move_card",
+ title: "Move Card",
+ description: "Move a card to a different column",
+ inputSchema: {
+ type: "object",
+ properties: {
+ account: { type: "string", description: "Account ID (required)" },
+ card: { type: "string", description: "Card number" },
+ to: { type: "string", description: "Column name, or: 'next', 'done', 'backlog'" }
+ },
+ required: %w[ account card to ]
+ },
+ annotations: {
+ readOnlyHint: false,
+ destructiveHint: false,
+ idempotentHint: true,
+ openWorldHint: false
+ }
+ }
+ ]
+
+ def list
+ { tools: TOOLS }
+ end
+
+ def call(name, arguments, identity:)
+ args = arguments.to_h.symbolize_keys
+
+ # Validate and set account context
+ account = resolve_account(args.delete(:account), identity)
+ user = identity.users.find_by!(account: account)
+
+ Current.account = account
+ Current.user = user
+
+ case name
+ when "create_board" then create_board(**args)
+ when "create_card" then create_card(**args)
+ when "update_card" then update_card(**args)
+ when "move_card" then move_card(**args)
+ else
+ raise ArgumentError, "Unknown tool: #{name}"
+ end
+ end
+
+ private
+ def resolve_account(account_id, identity)
+ raise ArgumentError, "account is required" if account_id.blank?
+
+ identity.accounts.find_by!(id: account_id)
+ rescue ActiveRecord::RecordNotFound
+ raise ArgumentError, "Account not found or not accessible: #{account_id}"
+ end
+
+ def create_board(name:, columns: nil)
+ columns ||= [ "Backlog", "In Progress", "Done" ]
+
+ board = Current.user.account.boards.create!(name: name, creator: Current.user, all_access: true)
+ columns.each { |col| board.columns.create!(name: col) }
+
+ tool_result board_summary(board)
+ end
+
+ def create_card(title:, board: nil, description: nil)
+ board = resolve_board(board)
+ card = board.cards.create! \
+ title: title,
+ description: description,
+ creator: Current.user,
+ status: "published"
+
+ tool_result card_summary(card)
+ end
+
+ def update_card(card:, title: nil, description: nil, comment: nil)
+ card = find_card(card)
+
+ card.update!(title: title) if title.present?
+ card.update!(description: description) if description.present?
+ card.comments.create!(body: comment, creator: Current.user) if comment.present?
+
+ tool_result card_summary(card.reload)
+ end
+
+ def move_card(card:, to:)
+ card = find_card(card)
+ column = resolve_column(card.board, to, card.column)
+ card.update!(column: column)
+
+ tool_result card_summary(card)
+ end
+
+ # Helpers
+
+ def resolve_board(identifier)
+ return Current.user.boards.order(updated_at: :desc).first! if identifier.blank?
+
+ Current.user.boards.find_by(id: identifier) ||
+ Current.user.boards.find_by!(name: identifier)
+ end
+
+ def find_card(identifier)
+ number = identifier.to_s.delete_prefix("#")
+ Current.user.accessible_cards.find_by!(number: number)
+ end
+
+ def resolve_column(board, target, current_column)
+ case target.to_s.downcase
+ when "done", "complete"
+ board.columns.sorted.last
+ when "backlog"
+ board.columns.sorted.first
+ when "next"
+ if current_column
+ current_column.right_column || board.columns.sorted.last
+ else
+ board.columns.sorted.second || board.columns.sorted.first
+ end
+ else
+ board.columns.find_by!(name: target)
+ end
+ end
+
+ def tool_result(content)
+ {
+ content: [ { type: "text", text: content.to_json } ]
+ }
+ end
+
+ def board_summary(board)
+ {
+ id: board.id,
+ name: board.name,
+ columns: board.columns.sorted.pluck(:name),
+ url: url_for(board)
+ }
+ end
+
+ def card_summary(card)
+ {
+ number: card.number,
+ title: card.title,
+ board: card.board.name,
+ column: card.column&.name,
+ url: url_for(card)
+ }
+ end
+
+ def url_for(record)
+ Rails.application.routes.url_helpers.polymorphic_url(record,
+ script_name: Current.account.slug,
+ **url_options)
+ end
+
+ def url_options
+ Rails.application.config.action_mailer.default_url_options || { host: "localhost" }
+ end
+end
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 %>
+
+
+
+<% end %>
+
+
+ <% if @connected_apps.any? %>
+ Apps you've authorized to access Fizzy on your behalf.
+
+
+
+ | App |
+ Permissions |
+ Connected |
+ |
+
+
+
+ <% @connected_apps.each do |client, tokens| %>
+ <%= render "my/connected_apps/connected_app", client: client, tokens: tokens %>
+ <% end %>
+
+
+ <% 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 %>
+
+<% 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..7eee031d18 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1,6 +1,19 @@
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"
+ get "/.well-known/mcp.json", to: "mcp#discovery"
+
+ post "/mcp", to: "mcp#create"
+
+ 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 +181,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/mcp_test.rb b/test/integration/mcp_test.rb
new file mode 100644
index 0000000000..c4f9ed882a
--- /dev/null
+++ b/test/integration/mcp_test.rb
@@ -0,0 +1,453 @@
+require "test_helper"
+
+class McpTest < ActionDispatch::IntegrationTest
+ setup do
+ @bearer_token = { "HTTP_AUTHORIZATION" => "Bearer #{identity_access_tokens(:davids_api_token).token}" }
+ @read_only_token = { "HTTP_AUTHORIZATION" => "Bearer #{identity_access_tokens(:jasons_api_token).token}" }
+ @account = accounts("37s")
+ end
+
+
+ # Discovery
+
+ test "discovery returns server metadata" do
+ untenanted do
+ get "/.well-known/mcp.json"
+ end
+
+ assert_response :success
+ body = response.parsed_body
+
+ assert_equal "Fizzy", body["name"]
+ assert_equal "2025-06-18", body["mcp_version"]
+ assert body["capabilities"].key?("tools")
+ assert body["capabilities"].key?("resources")
+ assert body["oauth"]["server"].present?
+ end
+
+
+ # Initialize
+
+ test "initialize returns protocol info" do
+ jsonrpc_call "initialize"
+
+ assert_response :success
+ result = response.parsed_body["result"]
+
+ assert_equal "2025-06-18", result["protocolVersion"]
+ assert_equal "Fizzy", result["serverInfo"]["name"]
+ assert_equal "Fizzy Kanban", result["serverInfo"]["title"]
+ assert result["capabilities"].key?("tools")
+ assert result["capabilities"].key?("resources")
+ end
+
+
+ # Protocol version header
+
+ test "requests require MCP-Protocol-Version header" do
+ jsonrpc_call "tools/list"
+
+ assert_response :success
+ end
+
+ test "unsupported protocol version returns error" do
+ untenanted do
+ post "/mcp",
+ params: jsonrpc_request("tools/list"),
+ headers: @bearer_token.merge("MCP-Protocol-Version" => "1999-01-01"),
+ as: :json
+ end
+
+ assert_response :bad_request
+ error = response.parsed_body["error"]
+ assert_match "Unsupported protocol version", error["message"]
+ end
+
+ test "notifications (requests without id) return 202 accepted" do
+ untenanted do
+ post "/mcp",
+ params: { jsonrpc: "2.0", method: "notifications/initialized" },
+ headers: @bearer_token.merge("MCP-Protocol-Version" => Mcp::PROTOCOL_VERSION),
+ as: :json
+ end
+
+ assert_response :accepted
+ assert_empty response.body
+ end
+
+ test "notifications execute side effects before returning 202" do
+ board = boards(:writebook)
+
+ assert_difference "board.cards.count", 1 do
+ untenanted do
+ post "/mcp",
+ params: {
+ jsonrpc: "2.0",
+ method: "tools/call",
+ params: { name: "create_card", arguments: { account: @account.id, board: board.id, title: "Created via notification" } }
+ },
+ headers: @bearer_token.merge("MCP-Protocol-Version" => Mcp::PROTOCOL_VERSION),
+ as: :json
+ end
+ end
+
+ assert_response :accepted
+ assert_empty response.body
+ assert_equal "Created via notification", board.cards.last.title
+ end
+
+
+ # Tools
+
+ test "tools/list returns available tools with annotations" do
+ jsonrpc_call "tools/list"
+
+ assert_response :success
+ tools = response.parsed_body["result"]["tools"]
+
+ tool_names = tools.map { |t| t["name"] }
+ assert_includes tool_names, "create_board"
+ assert_includes tool_names, "create_card"
+ assert_includes tool_names, "update_card"
+ assert_includes tool_names, "move_card"
+
+ # Check tool has title and annotations
+ create_board = tools.find { |t| t["name"] == "create_board" }
+ assert_equal "Create Board", create_board["title"]
+ assert create_board["annotations"].key?("readOnlyHint")
+ assert create_board["annotations"].key?("destructiveHint")
+ end
+
+ test "tools/call create_board creates a new board" do
+ assert_difference "Board.count", 1 do
+ jsonrpc_call "tools/call", name: "create_board", arguments: { account: @account.id, name: "Agent Workspace" }
+ end
+
+ assert_response :success
+ content = JSON.parse(response.parsed_body.dig("result", "content", 0, "text"))
+
+ assert_equal "Agent Workspace", content["name"]
+ assert_includes content["columns"], "Backlog"
+ assert_includes content["columns"], "In Progress"
+ assert_includes content["columns"], "Done"
+ end
+
+ test "tools/call create_board with custom columns" do
+ jsonrpc_call "tools/call", name: "create_board", arguments: {
+ account: @account.id,
+ name: "Custom Flow",
+ columns: [ "Queue", "Active", "Complete" ]
+ }
+
+ assert_response :success
+ content = JSON.parse(response.parsed_body.dig("result", "content", 0, "text"))
+
+ assert_equal [ "Queue", "Active", "Complete" ], content["columns"]
+ end
+
+ test "tools/call create_card creates a new card" do
+ board = boards(:writebook)
+
+ assert_difference "Card.count", 1 do
+ jsonrpc_call "tools/call", name: "create_card", arguments: {
+ account: @account.id,
+ title: "New feature request",
+ board: board.name,
+ description: "Detailed description here"
+ }
+ end
+
+ assert_response :success
+ content = JSON.parse(response.parsed_body.dig("result", "content", 0, "text"))
+
+ assert_equal "New feature request", content["title"]
+ assert_equal "Writebook", content["board"]
+ end
+
+ test "tools/call create_card uses most recent board when not specified" do
+ assert_difference "Card.count", 1 do
+ jsonrpc_call "tools/call", name: "create_card", arguments: {
+ account: @account.id,
+ title: "Quick card"
+ }
+ end
+
+ assert_response :success
+ end
+
+ test "tools/call without account returns error" do
+ jsonrpc_call "tools/call", name: "create_board", arguments: { name: "No Account" }
+
+ assert_response :bad_request
+ error = response.parsed_body["error"]
+ assert_match "account is required", error["message"]
+ end
+
+ test "tools/call with invalid account returns error" do
+ jsonrpc_call "tools/call", name: "create_board", arguments: {
+ account: "00000000-0000-0000-0000-000000000000",
+ name: "Invalid Account"
+ }
+
+ assert_response :bad_request
+ error = response.parsed_body["error"]
+ assert_match "Account not found", error["message"]
+ end
+
+ test "tools/call update_card updates title" do
+ card = cards(:logo)
+
+ jsonrpc_call "tools/call", name: "update_card", arguments: {
+ account: @account.id,
+ card: card.number.to_s,
+ title: "Updated title"
+ }
+
+ assert_response :success
+ assert_equal "Updated title", card.reload.title
+ end
+
+ test "tools/call update_card adds comment" do
+ card = cards(:logo)
+
+ assert_difference "Comment.count", 1 do
+ jsonrpc_call "tools/call", name: "update_card", arguments: {
+ account: @account.id,
+ card: "##{card.number}",
+ comment: "Progress update from agent"
+ }
+ end
+
+ assert_response :success
+ assert_equal "Progress update from agent", card.comments.last.body.to_plain_text
+ end
+
+ test "tools/call move_card moves to column by name" do
+ card = cards(:logo)
+ assert_equal "Triage", card.column.name
+
+ jsonrpc_call "tools/call", name: "move_card", arguments: {
+ account: @account.id,
+ card: card.number.to_s,
+ to: "In progress"
+ }
+
+ assert_response :success
+ assert_equal "In progress", card.reload.column.name
+ end
+
+ test "tools/call move_card moves to done" do
+ card = cards(:logo)
+
+ jsonrpc_call "tools/call", name: "move_card", arguments: {
+ account: @account.id,
+ card: card.number.to_s,
+ to: "done"
+ }
+
+ assert_response :success
+ assert_equal "Review", card.reload.column.name # Last column
+ end
+
+ test "tools/call move_card moves to backlog" do
+ card = cards(:text)
+ assert_equal "In progress", card.column.name
+
+ jsonrpc_call "tools/call", name: "move_card", arguments: {
+ account: @account.id,
+ card: card.number.to_s,
+ to: "backlog"
+ }
+
+ assert_response :success
+ assert_equal "Triage", card.reload.column.name # First column
+ end
+
+ test "tools/call move_card moves to next column" do
+ card = cards(:logo)
+ assert_equal "Triage", card.column.name
+
+ jsonrpc_call "tools/call", name: "move_card", arguments: {
+ account: @account.id,
+ card: card.number.to_s,
+ to: "next"
+ }
+
+ assert_response :success
+ assert_equal "In progress", card.reload.column.name
+ end
+
+ test "tools/call with read-only token fails for write operations" do
+ untenanted do
+ post "/mcp",
+ params: jsonrpc_request("tools/call", name: "create_board", arguments: { account: @account.id, name: "Should fail" }),
+ headers: @read_only_token.merge("MCP-Protocol-Version" => Mcp::PROTOCOL_VERSION),
+ as: :json
+ end
+
+ assert_response :forbidden
+ end
+
+ test "tools/list succeeds with read-only token" do
+ untenanted do
+ post "/mcp",
+ params: jsonrpc_request("tools/list"),
+ headers: @read_only_token.merge("MCP-Protocol-Version" => Mcp::PROTOCOL_VERSION),
+ as: :json
+ end
+
+ assert_response :success
+ assert response.parsed_body["result"]["tools"].is_a?(Array)
+ end
+
+ test "resources/list succeeds with read-only token" do
+ untenanted do
+ post "/mcp",
+ params: jsonrpc_request("resources/list"),
+ headers: @read_only_token.merge("MCP-Protocol-Version" => Mcp::PROTOCOL_VERSION),
+ as: :json
+ end
+
+ assert_response :success
+ assert response.parsed_body["result"]["resources"].is_a?(Array)
+ end
+
+ test "resources/read succeeds with read-only token" do
+ untenanted do
+ post "/mcp",
+ params: jsonrpc_request("resources/read", uri: "fizzy://accounts"),
+ headers: @read_only_token.merge("MCP-Protocol-Version" => Mcp::PROTOCOL_VERSION),
+ as: :json
+ end
+
+ assert_response :success
+ assert response.parsed_body["result"]["contents"].is_a?(Array)
+ end
+
+
+ # Resources
+
+ test "resources/list returns available resources" do
+ jsonrpc_call "resources/list"
+
+ assert_response :success
+ resources = response.parsed_body["result"]["resources"]
+
+ uris = resources.map { |r| r["uri"] || r["uriTemplate"] }
+ assert_includes uris, "fizzy://accounts"
+ assert_includes uris, "fizzy://accounts/{account_id}/overview"
+ assert_includes uris, "fizzy://accounts/{account_id}/boards/{id}"
+ assert_includes uris, "fizzy://accounts/{account_id}/cards/{number}"
+
+ # Check resources have title
+ accounts_resource = resources.find { |r| r["uri"] == "fizzy://accounts" }
+ assert_equal "Available Accounts", accounts_resource["title"]
+ end
+
+ test "resources/read accounts returns list of accessible accounts" do
+ jsonrpc_call "resources/read", uri: "fizzy://accounts"
+
+ assert_response :success
+ content = JSON.parse(response.parsed_body.dig("result", "contents", 0, "text"))
+
+ assert content["accounts"].is_a?(Array)
+ account_names = content["accounts"].map { |a| a["name"] }
+ assert_includes account_names, "37signals"
+ end
+
+ test "resources/read overview returns boards and activity" do
+ jsonrpc_call "resources/read", uri: "fizzy://accounts/#{@account.id}/overview"
+
+ assert_response :success
+ content = JSON.parse(response.parsed_body.dig("result", "contents", 0, "text"))
+
+ assert_equal @account.id, content["account"]["id"]
+ assert content["boards"].is_a?(Array)
+ assert content["in_progress"].is_a?(Array)
+ assert content["recent_activity"].is_a?(Array)
+ end
+
+ test "resources/read board returns board details" do
+ board = boards(:writebook)
+
+ jsonrpc_call "resources/read", uri: "fizzy://accounts/#{@account.id}/boards/#{board.id}"
+
+ assert_response :success
+ content = JSON.parse(response.parsed_body.dig("result", "contents", 0, "text"))
+
+ assert_equal board.id, content["id"]
+ assert_equal "Writebook", content["name"]
+ assert content["columns"].is_a?(Array)
+ end
+
+ test "resources/read card returns card details" do
+ card = cards(:logo)
+
+ jsonrpc_call "resources/read", uri: "fizzy://accounts/#{@account.id}/cards/#{card.number}"
+
+ assert_response :success
+ content = JSON.parse(response.parsed_body.dig("result", "contents", 0, "text"))
+
+ assert_equal card.number, content["number"]
+ assert_equal card.title, content["title"]
+ end
+
+ test "resources/read with invalid account returns error" do
+ jsonrpc_call "resources/read", uri: "fizzy://accounts/00000000-0000-0000-0000-000000000000/overview"
+
+ assert_response :bad_request
+ error = response.parsed_body["error"]
+ assert_match "Account not found", error["message"]
+ end
+
+
+ # Error handling
+
+ test "unknown method returns method_not_found error" do
+ jsonrpc_call "unknown/method"
+
+ assert_response :not_found
+ error = response.parsed_body["error"]
+
+ assert_equal(-32601, error["code"])
+ end
+
+ test "unknown tool returns error" do
+ jsonrpc_call "tools/call", name: "unknown_tool", arguments: { account: @account.id }
+
+ assert_response :bad_request
+ error = response.parsed_body["error"]
+
+ assert_equal(-32602, error["code"])
+ assert_match "Unknown tool", error["message"]
+ end
+
+ test "card not found returns error" do
+ jsonrpc_call "tools/call", name: "move_card", arguments: { account: @account.id, card: "99999", to: "done" }
+
+ assert_response :bad_request
+ error = response.parsed_body["error"]
+
+ assert_match "not found", error["message"]
+ end
+
+
+ private
+ def jsonrpc_call(method, **params)
+ untenanted do
+ post "/mcp",
+ params: jsonrpc_request(method, **params),
+ headers: @bearer_token.merge("MCP-Protocol-Version" => Mcp::PROTOCOL_VERSION),
+ as: :json
+ end
+ end
+
+ def jsonrpc_request(method, **params)
+ {
+ jsonrpc: "2.0",
+ id: SecureRandom.uuid,
+ method: method,
+ params: params.presence
+ }.compact
+ end
+end
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