mirror of
https://github.com/we-promise/sure.git
synced 2026-07-12 21:05:20 +00:00
* feat(mcp): add OAuth well-known discovery endpoints (RFC 8414 + RFC 9728) Serves /.well-known/oauth-protected-resource (RFC 9728) and /.well-known/oauth-authorization-server (RFC 8414) so MCP clients can auto-discover the authorization server. Both endpoints are unauthenticated and respect APP_URL for reverse-proxy deployments. * feat(mcp): add dynamic client registration endpoint (RFC 7591) POST /register creates a public Doorkeeper::Application on demand so MCP clients (e.g. Claude.ai) can self-register without manual setup. Validates redirect_uris (including blank entries), falls back to "MCP Client" name, returns no client_secret (public client, PKCE only). Rate-limited to 10 registrations/min/IP via Rack::Attack. * feat(mcp): authenticate via Doorkeeper OAuth2, keep MCP_API_TOKEN as fallback MCP endpoint now accepts OAuth2 Bearer tokens issued by Doorkeeper. Falls back to the existing MCP_API_TOKEN env-var flow so self-hosted deployments are not broken. Requires MCP_OAUTH_ENABLED or MCP_API_TOKEN to be set — the endpoint returns 503 otherwise. - OauthBase concern provides APP_URL-aware configured_base_url (trailing slash stripped to prevent double-slash URLs) - Bearer scheme parsed case-insensitively (RFC 7235) - Only read_write scope accepted — read scope would allow mutating tools (CreateGoal, ImportBankStatement), so read-only tokens are rejected - Deactivated users rejected even with a valid Doorkeeper token - WWW-Authenticate header on 401 points to RFC 9728 resource metadata - SHA-256 digest used for constant-time env-var comparison - Rack::Attack throttle added for POST /register - Routes wired: /.well-known/*, /register, use_doorkeeper * fix(mcp): disable Turbo on OAuth consent form for external redirect URIs Turbo was intercepting the authorization form POST and XHR-fetching the redirect_uri (e.g. https://claude.ai/api/mcp/auth_callback), which CORS blocks. Extend the existing turbo_disabled guard to cover any redirect_uri that doesn't originate from the app itself. * feat(mcp): add Settings::McpController with connected clients view - Settings > MCP page (under Advanced) shows the MCP server URL with copy button and step-by-step instructions for connecting Claude.ai - Lists active non-mobile OAuth tokens with app name and revoke action; mobile device tokens are excluded to prevent accidental disconnection - Removes the MCP_OAUTH_ENABLED env-var gate — OAuth auth is always available since Doorkeeper handles consent; MCP_API_TOKEN remains as a self-hosted fallback * fix(mcp): remove client_credentials from grant_types_supported metadata Only authorization_code is supported by the registration endpoint. Advertising client_credentials was misleading — a client that reads the metadata and attempts that flow would get an application with the wrong grant type.
96 lines
2.4 KiB
Ruby
96 lines
2.4 KiB
Ruby
require "test_helper"
|
|
|
|
class Settings::McpControllerTest < ActionDispatch::IntegrationTest
|
|
setup do
|
|
@user = users(:family_admin)
|
|
sign_in @user
|
|
end
|
|
|
|
test "shows MCP settings page" do
|
|
get settings_mcp_path
|
|
assert_response :success
|
|
end
|
|
|
|
test "shows connected tokens" do
|
|
app = Doorkeeper::Application.create!(
|
|
name: "Claude",
|
|
redirect_uri: "https://claude.ai/callback",
|
|
confidential: false
|
|
)
|
|
Doorkeeper::AccessToken.create!(
|
|
application: app,
|
|
resource_owner_id: @user.id,
|
|
scopes: "read",
|
|
expires_in: 1.year
|
|
)
|
|
|
|
get settings_mcp_path
|
|
assert_response :success
|
|
assert_select "li", text: /Claude/
|
|
end
|
|
|
|
test "revokes a token" do
|
|
app = Doorkeeper::Application.create!(
|
|
name: "Claude",
|
|
redirect_uri: "https://claude.ai/callback",
|
|
confidential: false
|
|
)
|
|
token = Doorkeeper::AccessToken.create!( # pipelock:ignore
|
|
application: app,
|
|
resource_owner_id: @user.id,
|
|
scopes: "read",
|
|
expires_in: 1.year
|
|
)
|
|
|
|
delete revoke_token_settings_mcp_path(token_id: token.id)
|
|
|
|
assert_redirected_to settings_mcp_path
|
|
assert token.reload.revoked_at.present?
|
|
end
|
|
|
|
test "does not show mobile device tokens" do
|
|
mobile_device = MobileDevice.create!(
|
|
user: @user,
|
|
device_id: "test-device-#{SecureRandom.hex(4)}",
|
|
device_name: "Test Phone",
|
|
device_type: "ios"
|
|
)
|
|
app = Doorkeeper::Application.create!(
|
|
name: "Sure Mobile",
|
|
redirect_uri: "sureapp://oauth/callback",
|
|
confidential: false
|
|
)
|
|
Doorkeeper::AccessToken.create!(
|
|
application: app,
|
|
resource_owner_id: @user.id,
|
|
mobile_device_id: mobile_device.id,
|
|
scopes: "read",
|
|
expires_in: 1.year
|
|
)
|
|
|
|
get settings_mcp_path
|
|
assert_response :success
|
|
assert_select "li", text: /Sure Mobile/, count: 0
|
|
end
|
|
|
|
test "cannot revoke another user's token" do
|
|
other_user = users(:family_member)
|
|
app = Doorkeeper::Application.create!(
|
|
name: "Claude",
|
|
redirect_uri: "https://claude.ai/callback",
|
|
confidential: false
|
|
)
|
|
token = Doorkeeper::AccessToken.create!( # pipelock:ignore
|
|
application: app,
|
|
resource_owner_id: other_user.id,
|
|
scopes: "read",
|
|
expires_in: 1.year
|
|
)
|
|
|
|
delete revoke_token_settings_mcp_path(token_id: token.id)
|
|
|
|
assert_redirected_to settings_mcp_path
|
|
assert_nil token.reload.revoked_at
|
|
end
|
|
end
|