mirror of
https://github.com/we-promise/sure.git
synced 2026-08-05 16:42:18 +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.
77 lines
3.5 KiB
Plaintext
77 lines
3.5 KiB
Plaintext
<% if params[:redirect_uri]&.start_with?('sureapp://') || params[:display] == 'mobile' %>
|
|
<meta name="turbo-visit-control" content="reload">
|
|
<% end %>
|
|
|
|
<div class="bg-container rounded-xl p-6 space-y-6">
|
|
<div class="space-y-2 text-center">
|
|
<p class="text-sm text-secondary">
|
|
<%= raw t(".prompt", client_name: content_tag(:span, @pre_auth.client.name, class: "font-medium text-primary")) %>
|
|
</p>
|
|
</div>
|
|
|
|
<% if @pre_auth.scopes.count > 0 %>
|
|
<div class="bg-surface-inset rounded-lg p-4 space-y-3">
|
|
<p class="text-sm font-medium text-primary"><%= t(".able_to") %>:</p>
|
|
<ul class="space-y-2">
|
|
<% @pre_auth.scopes.each do |scope| %>
|
|
<li class="flex items-start gap-2 text-sm text-secondary">
|
|
<%= icon("check", class: "w-4 h-4 mt-0.5 text-success") %>
|
|
<span><%= t scope, scope: [:doorkeeper, :scopes] %></span>
|
|
</li>
|
|
<% end %>
|
|
</ul>
|
|
</div>
|
|
<% end %>
|
|
|
|
<div class="space-y-3">
|
|
<% turbo_disabled = params[:redirect_uri]&.start_with?("sureapp://") || params[:display] == "mobile" || !params[:redirect_uri]&.start_with?(root_url) %>
|
|
<%= form_tag oauth_authorization_path, method: :post, class: "w-full", data: { turbo: !turbo_disabled } do %>
|
|
<%= hidden_field_tag :client_id, @pre_auth.client.uid, id: nil %>
|
|
<%= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri, id: nil %>
|
|
<%= hidden_field_tag :state, @pre_auth.state, id: nil %>
|
|
<%= hidden_field_tag :response_type, @pre_auth.response_type, id: nil %>
|
|
<%= hidden_field_tag :response_mode, @pre_auth.response_mode, id: nil %>
|
|
<%= hidden_field_tag :scope, @pre_auth.scope, id: nil %>
|
|
<%= hidden_field_tag :code_challenge, @pre_auth.code_challenge, id: nil %>
|
|
<%= hidden_field_tag :code_challenge_method, @pre_auth.code_challenge_method, id: nil %>
|
|
<% if params[:display].present? %>
|
|
<%= hidden_field_tag :display, params[:display], id: nil %>
|
|
<% end %>
|
|
<%= render DS::Button.new(
|
|
text: t("doorkeeper.authorizations.buttons.authorize"),
|
|
variant: :primary,
|
|
size: :lg,
|
|
full_width: true,
|
|
href: oauth_authorization_path,
|
|
data: { disable_with: "Authorizing..." }
|
|
) %>
|
|
<% end %>
|
|
|
|
<%= form_tag oauth_authorization_path, method: :delete, class: "w-full", data: { turbo: !turbo_disabled } do %>
|
|
<%= hidden_field_tag :client_id, @pre_auth.client.uid, id: nil %>
|
|
<%= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri, id: nil %>
|
|
<%= hidden_field_tag :state, @pre_auth.state, id: nil %>
|
|
<%= hidden_field_tag :response_type, @pre_auth.response_type, id: nil %>
|
|
<%= hidden_field_tag :response_mode, @pre_auth.response_mode, id: nil %>
|
|
<%= hidden_field_tag :scope, @pre_auth.scope, id: nil %>
|
|
<%= hidden_field_tag :code_challenge, @pre_auth.code_challenge, id: nil %>
|
|
<%= hidden_field_tag :code_challenge_method, @pre_auth.code_challenge_method, id: nil %>
|
|
<% if params[:display].present? %>
|
|
<%= hidden_field_tag :display, params[:display], id: nil %>
|
|
<% end %>
|
|
<%= render DS::Button.new(
|
|
text: t("doorkeeper.authorizations.buttons.deny"),
|
|
variant: :outline,
|
|
size: :lg,
|
|
full_width: true,
|
|
href: oauth_authorization_path,
|
|
data: { disable_with: "Denying..." }
|
|
) %>
|
|
<% end %>
|
|
</div>
|
|
|
|
<p class="text-xs text-subdued text-center">
|
|
By authorizing, you allow this app to access your <%= product_name %> data according to the permissions above.
|
|
</p>
|
|
</div>
|