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 %> +
+ <%= back_link_to "My profile", user_path(Current.user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> +
+ +

<%= @page_title %>

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

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

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

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

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

Authorization Error

+ +

+ <%= @error_description %> +

+ +

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

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

<%= @page_title %>

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

<%= @client.name %>

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

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

+ <% end %> +
+ +
+ <%= f.label :scope, "Permission" %> + <% scope_options = @client.scopes.include?("write") ? { "Read" => "read", "Read + Write" => "write" } : { "Read" => "read" } %> + <%= f.select :scope, options_for_select(scope_options, @scope), {}, class: "input input--select" %> +
+ +
+ <%= f.button type: :submit, class: "btn btn--link txt-medium" do %> + Authorize + <% end %> + + <%= f.button type: :submit, name: :error, value: "access_denied", class: "btn btn--outline txt-medium" do %> + Deny + <% end %> +
+ <% end %> +
diff --git a/app/views/users/_access_tokens.html.erb b/app/views/users/_access_tokens.html.erb index 285f87149e..36c6531162 100644 --- a/app/views/users/_access_tokens.html.erb +++ b/app/views/users/_access_tokens.html.erb @@ -1,4 +1,4 @@ -
+

Developer

Manage <%= link_to "personal access tokens", my_access_tokens_path, class: "btn btn--plain txt-link" %> used with the Fizzy developer API.

\ No newline at end of file diff --git a/app/views/users/_connected_apps.html.erb b/app/views/users/_connected_apps.html.erb new file mode 100644 index 0000000000..fd9411198d --- /dev/null +++ b/app/views/users/_connected_apps.html.erb @@ -0,0 +1,4 @@ +
+

Connected Apps

+

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

+
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index 7cf5bdf7c1..2f3bfeda4b 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -52,6 +52,7 @@
<%= render "users/theme" %> <%= render "users/transfer", user: @user %> + <%= render "users/connected_apps" if Current.identity.access_tokens.oauth.exists? %> <%= render "users/access_tokens" %>
<% end %> diff --git a/config/routes.rb b/config/routes.rb index 97e34290cb..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