+
<%= f.button type: :submit, class: "btn btn--link txt-medium" do %>
Authorize
<% end %>
diff --git a/config/routes.rb b/config/routes.rb
index c069aacb24..4a1e357032 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -5,7 +5,11 @@
get "/.well-known/oauth-protected-resource", to: "oauth/protected_resource_metadata#show"
namespace :oauth do
- resource :authorization, only: %i[ new create ]
+ resource :authorization, only: %i[ new create ] do
+ scope module: :authorizations do
+ resource :switch, only: :create
+ end
+ end
# The delegation exchange shares the token endpoint, told apart by its grant type.
post "token", to: "token_exchanges#create", as: nil,
constraints: Oauth::TokenDelegation.method(:request?)
diff --git a/test/controllers/my/connected_apps_controller_test.rb b/test/controllers/my/connected_apps_controller_test.rb
index 9eb5eb9dd4..fc43c5bbbd 100644
--- a/test/controllers/my/connected_apps_controller_test.rb
+++ b/test/controllers/my/connected_apps_controller_test.rb
@@ -1,6 +1,8 @@
require "test_helper"
class My::ConnectedAppsControllerTest < ActionDispatch::IntegrationTest
+ include OauthAvailabilityTestHelper
+
setup do
sign_in_as :david
@identity = identities(:david)
@@ -15,6 +17,67 @@ class My::ConnectedAppsControllerTest < ActionDispatch::IntegrationTest
assert_match client.name, response.body
end
+ test "an app whose grants are bound to accounts is shown reaching only those" do
+ resource = "https://mcp.example.com/mcp"
+ saved, Rails.application.config.x.oauth.mcp_resources = Rails.application.config.x.oauth.mcp_resources, [ resource ]
+ @identity.access_tokens.create! oauth_client: oauth_clients(:mcp_client), permission: :read, audience: resource, account: accounts("37s")
+
+ get my_connected_apps_path
+
+ assert_select ".connected-app__access", text: /in #{accounts("37s").name}\z/
+ assert_select ".connected-app__access bdi", text: accounts("37s").name
+ assert_no_match "in every account you belong to", response.body
+ ensure
+ Rails.application.config.x.oauth.mcp_resources = saved
+ end
+
+ test "an app's reach pairs each permission with the accounts it holds it in" do
+ resource = "https://mcp.example.com/mcp"
+ saved, Rails.application.config.x.oauth.mcp_resources = Rails.application.config.x.oauth.mcp_resources, [ resource ]
+ @identity.access_tokens.create! oauth_client: oauth_clients(:mcp_client), permission: :write, audience: resource, account: accounts("37s")
+ accounts(:initech).users.create! identity: @identity, name: "David"
+ @identity.access_tokens.create! oauth_client: oauth_clients(:mcp_client), permission: :read, audience: resource, account: accounts(:initech)
+
+ get my_connected_apps_path
+
+ assert_select ".connected-app__access", count: 2
+ assert_select ".connected-app__access", text: /Read and write.*in #{accounts("37s").name}\z/m
+ assert_select ".connected-app__access", text: /\ARead only.*in #{accounts(:initech).name}\z/m
+ ensure
+ Rails.application.config.x.oauth.mcp_resources = saved
+ end
+
+ test "a grant bound to a deleted account reaches nothing, and the page still renders" do
+ resource = "https://mcp.example.com/mcp"
+ saved, Rails.application.config.x.oauth.mcp_resources = Rails.application.config.x.oauth.mcp_resources, [ resource ]
+ grant = @identity.access_tokens.create! oauth_client: oauth_clients(:mcp_client), permission: :read, audience: resource, account: accounts("37s")
+ grant.update_columns account_id: ActiveRecord::FixtureSet.identify("deleted", :uuid)
+
+ get my_connected_apps_path
+
+ assert_response :success
+ assert_select ".connected-app__access", count: 0
+ ensure
+ Rails.application.config.x.oauth.mcp_resources = saved
+ end
+
+ test "a grant bound to an account that's since cancelled reaches nothing, and the app is still listed to disconnect" do
+ resource = "https://mcp.example.com/mcp"
+ saved, Rails.application.config.x.oauth.mcp_resources = Rails.application.config.x.oauth.mcp_resources, [ resource ]
+ client = oauth_clients(:mcp_client)
+ user = accounts(:initech).users.create! identity: @identity, name: "David"
+ @identity.access_tokens.create! oauth_client: client, permission: :read, audience: resource, account: accounts(:initech)
+ accounts(:initech).create_cancellation!(initiated_by: user)
+
+ get my_connected_apps_path
+
+ assert_response :success
+ assert_match client.name, response.body
+ assert_select ".connected-app__access", count: 0
+ ensure
+ Rails.application.config.x.oauth.mcp_resources = saved
+ end
+
test "index and destroy work untenanted, so an identity without an account can still disconnect apps" do
client = oauth_clients(:mcp_client)
@identity.access_tokens.create!(oauth_client: client, permission: :read)
@@ -75,4 +138,47 @@ class My::ConnectedAppsControllerTest < ActionDispatch::IntegrationTest
delete my_connected_app_path(client)
assert_response :not_found
end
+
+ test "index groups apps by kind and says what each can do" do
+ @identity.access_tokens.create!(oauth_client: oauth_clients(:mcp_client), permission: :read)
+ @identity.access_tokens.create!(oauth_client: oauth_clients(:confidential_client), permission: :write)
+
+ get my_connected_apps_path
+
+ assert_response :success
+ assert_select ".connected-apps__group", count: 2
+ assert_select "#connected_apps_web + .connected-app, #connected_apps_web ~ .connected-app", text: /Confidential Connector.*Read and write/m
+ assert_select "#connected_apps_local ~ .connected-app", text: /Test MCP Client.*Name set by an app on your device.*Read only/m
+ assert_select "#connected_apps_fizzy", count: 0
+ assert_select ".connected-app", text: /access last renewed/
+ end
+
+ test "index names where a self-registered web app's access goes" do
+ @identity.access_tokens.create!(oauth_client: oauth_clients(:confidential_client), permission: :read)
+
+ get my_connected_apps_path
+
+ assert_select ".connected-app", text: /Name set by the app at connector.example.com/
+ end
+
+ test "index shows an empty state when nothing is connected" do
+ get my_connected_apps_path
+
+ assert_response :success
+ assert_select ".connected-apps__empty", text: /Nothing connected yet/
+ assert_select ".connected-app", count: 0
+ end
+
+ test "index names every remote host a self-registered app can send its access to, not just the first" do
+ client = with_dcr_allowed_https_hosts "one.example.com", "two.example.com" do
+ Oauth::Client.create! name: "Two Homes", dynamically_registered: true,
+ redirect_uris: %w[ http://127.0.0.1:8888/callback https://one.example.com/cb https://two.example.com:8443/cb ]
+ end
+ @identity.access_tokens.create!(oauth_client: client, permission: :read)
+
+ get my_connected_apps_path
+
+ assert_select "#connected_apps_web ~ .connected-app", text: /Name set by the app at one.example.com or two.example.com:8443/
+ assert_no_match "Name set by an app on your device", response.body
+ end
end
diff --git a/test/helpers/oauth_helper_test.rb b/test/helpers/oauth_helper_test.rb
new file mode 100644
index 0000000000..e73d904ef7
--- /dev/null
+++ b/test/helpers/oauth_helper_test.rb
@@ -0,0 +1,77 @@
+require "test_helper"
+
+class OauthHelperTest < ActionView::TestCase
+ test "a self name of nothing but direction controls reads as an unnamed app" do
+ assert_equal "Unnamed app", oauth_client_self_name(Oauth::Client.new(name: "\u202E\u2066\u2069"))
+ end
+
+ test "a self-registered app is identified by its callback's host, port included when it isn't the default" do
+ client = Oauth::Client.new name: "Hosted", dynamically_registered: true
+
+ assert_equal "connector.example.com", oauth_client_display_identity(client, "https://connector.example.com/callback")
+ assert_equal "connector.example.com:8443", oauth_client_display_identity(client, "https://connector.example.com:8443/callback")
+ assert_equal "an app on your device", oauth_client_display_identity(client, "http://127.0.0.1:8888/callback")
+ assert_equal "an app on your device", oauth_client_display_identity(client, "com.example.app:/callback")
+ end
+
+ test "a registered app is identified by its name" do
+ assert_equal "Trusted First-Party App", oauth_client_display_identity(oauth_clients(:trusted_client), "https://app.example.com/oauth/callback")
+ end
+
+ test "a read-only client is offered read alone" do
+ reader = Oauth::Client.new scopes: %w[ read ]
+
+ assert_equal %w[ read ], oauth_access_levels(reader).map(&:first)
+ assert_equal [ "read", "read write" ], oauth_access_levels(oauth_clients(:mcp_client)).map(&:first)
+ end
+
+ test "connected apps are grouped by kind, in a fixed order, skipping empty groups" do
+ web = oauth_clients(:confidential_client)
+ local = oauth_clients(:mcp_client)
+
+ groups = connected_app_groups([ [ local, [] ], [ web, [] ] ])
+
+ assert_equal %i[ web local ], groups.map(&:first)
+ assert_equal [ [ web, [] ] ], groups.first.last
+ end
+
+ test "the account picker offers each account, preselecting the chosen one" do
+ accounts = Account.order(:name).first(2)
+
+ form_with(url: "/oauth/authorization") do |form|
+ render partial: "oauth/authorizations/account_picker", locals: { form: form, accounts: accounts, selected_account_id: accounts.second.id }
+ end
+
+ assert_select "input[type=radio][name=account_id]", count: accounts.size
+ assert_select "input[type=radio][name=account_id][checked][value=?]", accounts.second.id.to_s
+ end
+
+ test "the account picker names a lone account and carries it hidden" do
+ account = Account.first
+
+ form_with(url: "/oauth/authorization") do |form|
+ render partial: "oauth/authorizations/account_picker", locals: { form: form, accounts: [ account ], selected_account_id: nil }
+ end
+
+ assert_select "input[type=radio]", count: 0
+ assert_select "input[type=hidden][name=account_id][value=?]", account.id.to_s
+ assert_select "bdi", text: account.name
+ end
+
+ test "a client's remote hosts skip its on-device callbacks and repeats" do
+ client = Oauth::Client.new redirect_uris: %w[ http://127.0.0.1/cb https://a.example.com/x https://a.example.com/y com.example.app:/cb https://b.example.com:8443/cb ]
+
+ assert_equal %w[ a.example.com b.example.com:8443 ], oauth_client_remote_hosts(client)
+ end
+
+ test "read's description names the people in every account, or in the one account a bound grant reaches" do
+ client = oauth_clients(:mcp_client)
+
+ assert_match "the people in your accounts.", oauth_access_levels(client).to_h.fetch("read")[:description]
+ assert_match "the people in your account.", oauth_access_levels(client, one_account: true).to_h.fetch("read")[:description]
+ end
+
+ test "a redirect's host reads in lowercase, as a browser reads it" do
+ assert_equal "connector.example.com:8443", oauth_redirect_host("https://Connector.EXAMPLE.com:8443/cb")
+ end
+end
diff --git a/test/integration/oauth_audience_test.rb b/test/integration/oauth_audience_test.rb
index d03874cebc..b1cdf961a8 100644
--- a/test/integration/oauth_audience_test.rb
+++ b/test/integration/oauth_audience_test.rb
@@ -25,9 +25,9 @@ class OauthAudienceTest < ActionDispatch::IntegrationTest
get_consent resource: MCP_RESOURCE
assert_response :success
- assert_select "select[name=account_id] option", 2
- assert_select "select[name=account_id] option[value=?]", accounts("37s").id
- assert_select "select[name=account_id] option[value=?]", accounts(:initech).id
+ assert_select "input[type=radio][name=account_id]", 2
+ assert_select "input[type=radio][name=account_id][value=?]", accounts("37s").id
+ assert_select "input[type=radio][name=account_id][value=?]", accounts(:initech).id
assert_select "input[type=hidden][name=resource][value=?]", MCP_RESOURCE
end
diff --git a/test/integration/oauth_authorization_switch_test.rb b/test/integration/oauth_authorization_switch_test.rb
new file mode 100644
index 0000000000..4a0e9fe4ec
--- /dev/null
+++ b/test/integration/oauth_authorization_switch_test.rb
@@ -0,0 +1,134 @@
+require "test_helper"
+
+class OauthAuthorizationSwitchTest < ActionDispatch::IntegrationTest
+ setup do
+ sign_in_as :david
+ @client = oauth_clients(:mcp_client)
+ end
+
+ test "the consent screen offers to switch who is signed in" do
+ untenanted { get new_oauth_authorization_path, params: authorization_params }
+
+ assert_select "form[action=?] button", oauth_authorization_switch_path(script_name: nil), text: "Not you?"
+ end
+
+ test "switching signs out and saves the authorization request, rebuilt from its checked parameters" do
+ assert_difference -> { identities(:david).sessions.count }, -1 do
+ post_switch authorization_params.merge(return_to: "https://evil.example.com/", account_id: "123")
+ end
+
+ assert_redirected_to new_session_path(script_name: nil)
+ assert_not cookies[:session_token].present?
+ assert_equal expected_return_url, session[:return_to_after_authenticating]
+ end
+
+ test "switching an audienced request signs out even when the signed-in identity has no account left" do
+ resource = "https://mcp.example.com/mcp"
+ saved, Rails.application.config.x.oauth.mcp_resources = Rails.application.config.x.oauth.mcp_resources, [ resource ]
+ identities(:david).users.each { |user| user.account.create_cancellation!(initiated_by: user) unless user.account.cancellation }
+
+ assert_difference -> { identities(:david).sessions.count }, -1 do
+ post_switch authorization_params.merge(resource: resource)
+ end
+
+ assert_redirected_to new_session_path(script_name: nil)
+ ensure
+ Rails.application.config.x.oauth.mcp_resources = saved
+ end
+
+ test "switching with a bearer token and no browser session is refused, not half-handled" do
+ token = identities(:david).access_tokens.create!(permission: :write)
+ reset!
+
+ assert_no_difference -> { identities(:david).sessions.count } do
+ post_switch authorization_params, as: :json, headers: { "Authorization" => "Bearer #{token.raw_token}" }
+ end
+
+ assert_response :forbidden
+ end
+
+ test "switching requires a same-origin request" do
+ with_forgery_protection do
+ assert_no_difference -> { identities(:david).sessions.count } do
+ post_switch authorization_params, headers: { "Sec-Fetch-Site" => "cross-site" }
+ end
+ end
+
+ assert_response :unprocessable_entity
+ end
+
+ test "a redirect the client didn't register is refused, leaving the person signed in and nothing saved" do
+ assert_no_difference -> { identities(:david).sessions.count } do
+ post_switch authorization_params(redirect_uri: "https://evil.example.com/callback")
+ end
+
+ assert_response :bad_request
+ assert cookies[:session_token].present?
+ assert_nil session[:return_to_after_authenticating]
+ end
+
+ test "an unknown client is refused, leaving the person signed in" do
+ assert_no_difference -> { identities(:david).sessions.count } do
+ post_switch authorization_params(client_id: "forged")
+ end
+
+ assert_response :bad_request
+ assert_select "code", text: "invalid_request"
+ assert_nil session[:return_to_after_authenticating]
+ end
+
+ test "a request too long to resume is refused before signing out" do
+ assert_no_difference -> { identities(:david).sessions.count } do
+ post_switch authorization_params(state: "x" * 2048)
+ end
+
+ assert_response :bad_request
+ assert_select "p", text: /too long to pick up again/
+ assert cookies[:session_token].present?
+ assert_nil session[:return_to_after_authenticating]
+ end
+
+ test "whoever signs in next lands back on consent for the same request, and the saved request is used once" do
+ post_switch authorization_params
+ sign_in_as :kevin
+
+ assert_equal expected_return_url, response.location
+ assert_nil session[:return_to_after_authenticating]
+
+ get response.location
+ assert_response :success
+ assert_select ".oauth-consent__identity strong", text: identities(:kevin).email_address
+
+ sign_in_as :kevin
+ assert_not_equal expected_return_url, response.location
+ end
+
+ private
+ def authorization_params(**overrides)
+ {
+ client_id: @client.client_id,
+ redirect_uri: @client.redirect_uris.first,
+ response_type: "code",
+ code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
+ code_challenge_method: "S256",
+ scope: "read",
+ state: "xyz123"
+ }.merge(overrides)
+ end
+
+ def expected_return_url
+ new_oauth_authorization_url(script_name: nil, **authorization_params)
+ end
+
+ def post_switch(params, **options)
+ untenanted { post oauth_authorization_switch_path, params: params, **options }
+ end
+
+ def with_forgery_protection
+ original = ActionController::Base.allow_forgery_protection
+ ActionController::Base.allow_forgery_protection = true
+ yield
+ ensure
+ ActionController::Base.allow_forgery_protection = original
+ end
+end
diff --git a/test/integration/oauth_client_metadata_documents_test.rb b/test/integration/oauth_client_metadata_documents_test.rb
index 599f24c697..350ee2acff 100644
--- a/test/integration/oauth_client_metadata_documents_test.rb
+++ b/test/integration/oauth_client_metadata_documents_test.rb
@@ -56,6 +56,25 @@ class OauthClientMetadataDocumentsTest < ActionDispatch::IntegrationTest
assert_predicate response.parsed_body["refresh_token"], :present?
end
+ test "switching who is signed in registers nothing" do
+ sign_in_as :david
+
+ untenanted { post oauth_authorization_switch_path, params: authorization }
+
+ assert_response :redirect
+ assert_nil Oauth::Client.find_presented(URL)
+ end
+
+ test "Connected apps names the host that set a metadata document client's name" do
+ sign_in_as :david
+ untenanted { post oauth_authorization_path, params: authorization }
+ identities(:david).access_tokens.create! oauth_client: Oauth::Client.find_presented(URL), permission: :read
+
+ untenanted { get my_connected_apps_path }
+
+ assert_select ".connected-app", text: /Name set by the app at claude\.ai/
+ end
+
test "denying registers nothing" do
sign_in_as :david
@@ -85,7 +104,7 @@ class OauthClientMetadataDocumentsTest < ActionDispatch::IntegrationTest
untenanted { get new_oauth_authorization_path, params: authorization.merge(client_id: url, redirect_uri: "https://tools.example.com/cb") }
assert_response :bad_request
- assert_equal "Unknown client", response.parsed_body["error_description"]
+ assert_select "p", text: "Unknown client"
assert_not_requested stub
end
@@ -109,7 +128,7 @@ class OauthClientMetadataDocumentsTest < ActionDispatch::IntegrationTest
end
assert_response :bad_request
- assert_equal "Unknown client", response.parsed_body["error_description"]
+ assert_select "p", text: "Unknown client"
end
test "a client_id array never reaches a metadata document client" do
@@ -141,7 +160,7 @@ class OauthClientMetadataDocumentsTest < ActionDispatch::IntegrationTest
untenanted { get new_oauth_authorization_path, params: authorization.merge(client_id: client.client_id) }
assert_response :bad_request
- assert_equal "Unknown client", response.parsed_body["error_description"]
+ assert_select "p", text: "Unknown client"
end
test "an unvetted redirect gets no error redirect before consent" do
diff --git a/test/integration/oauth_consent_screen_test.rb b/test/integration/oauth_consent_screen_test.rb
new file mode 100644
index 0000000000..f6f11572d6
--- /dev/null
+++ b/test/integration/oauth_consent_screen_test.rb
@@ -0,0 +1,93 @@
+require "test_helper"
+
+class OauthConsentScreenTest < ActionDispatch::IntegrationTest
+ setup do
+ sign_in_as :david
+ end
+
+ test "a self-registered web app goes by its callback's host, its own name quoted beside it" do
+ client = Oauth::Client.create! name: "Totally Fizzy", dynamically_registered: true,
+ redirect_uris: %w[ https://connector.example.com/callback ]
+
+ get_consent_screen client
+
+ assert_response :success
+ assert_select "title", text: /Authorize the app at connector\.example\.com/
+ assert_select "h1", text: "Authorize the app at connector.example.com"
+ assert_select ".oauth-client__name", text: "connector.example.com"
+ assert_select ".oauth-client__self-name", text: /\ASays it’s “Totally Fizzy”\z/
+ assert_select ".oauth-client__warning", text: /\AWe haven’t checked this app\. Anyone can run an app at any address, so only authorize it if you trust connector\.example\.com\.\z/
+ assert_select ".oauth-consent__destination strong", text: "connector.example.com"
+ end
+
+ test "a self-registered app on this device is named as one" do
+ get_consent_screen oauth_clients(:mcp_client)
+
+ assert_response :success
+ assert_select ".oauth-client__name", text: "An app on your device"
+ assert_select ".oauth-client__self-name", text: /\ASays it’s “Test MCP Client”\z/
+ assert_select ".oauth-client__warning", text: /\AWe haven’t checked this app\. It runs on your device and will get this access, so only authorize it if you started it\.\z/
+ assert_select ".oauth-consent__destination", text: /the app on your device/
+ end
+
+ test "a trusted app is known by its name and carries no warning" do
+ get_consent_screen oauth_clients(:trusted_client)
+
+ assert_response :success
+ assert_select ".oauth-client__name", text: "Trusted First-Party App"
+ assert_select ".oauth-client__made-by", text: "Made by 37signals"
+ assert_select ".oauth-client__self-name", count: 0
+ assert_select ".oauth-client__warning", count: 0
+ end
+
+ test "bidirectional controls are stripped from a self-registered name" do
+ client = Oauth::Client.create! name: "Goodppa App", dynamically_registered: true,
+ redirect_uris: %w[ http://127.0.0.1:8888/callback ]
+
+ get_consent_screen client
+
+ assert_response :success
+ assert_no_match "", response.body
+ assert_select ".oauth-client__self-name bdi", text: "Goodppa App"
+ end
+
+ test "each access level says what it lets the app do" do
+ get_consent_screen oauth_clients(:mcp_client), scope: "read write"
+
+ assert_response :success
+ assert_select ".oauth-access__choice", count: 2
+ assert_select ".oauth-access__choice", text: /Read only.*the people in your accounts\. It can't change anything/m
+ assert_select ".oauth-access__choice", text: /Read and write.*delete cards/m
+ assert_select ".oauth-access__reach", text: /every account you belong to/
+ end
+
+ test "the person sees who they're signed in as" do
+ get_consent_screen oauth_clients(:mcp_client)
+
+ assert_select ".oauth-consent__identity strong", text: identities(:david).email_address
+ end
+
+ test "an error before consent is shown on Fizzy, with what to do next" do
+ get_consent_screen oauth_clients(:mcp_client), redirect_uri: "https://evil.example.com/callback"
+
+ assert_response :bad_request
+ assert_select "h2", text: "Can't authorize this app"
+ assert_select "p", text: /Nothing was shared/
+ assert_select "code", text: "invalid_request"
+ end
+
+ private
+ def get_consent_screen(client, **overrides)
+ untenanted do
+ get new_oauth_authorization_path, params: {
+ client_id: client.client_id,
+ redirect_uri: client.redirect_uris.first,
+ response_type: "code",
+ code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
+ code_challenge_method: "S256",
+ scope: "read",
+ state: "xyz123"
+ }.merge(overrides)
+ end
+ end
+end
diff --git a/test/integration/oauth_dcr_redirect_allowlist_test.rb b/test/integration/oauth_dcr_redirect_allowlist_test.rb
index d0072ac2fc..272938e6b2 100644
--- a/test/integration/oauth_dcr_redirect_allowlist_test.rb
+++ b/test/integration/oauth_dcr_redirect_allowlist_test.rb
@@ -89,7 +89,7 @@ class OauthDcrRedirectAllowlistTest < ActionDispatch::IntegrationTest
end
assert_response :success
assert_includes response.headers["Content-Security-Policy"], "cursor:"
- assert_select "strong", text: "cursor://anysphere.cursor-mcp"
+ assert_select ".oauth-consent__destination", text: /the app on your device/
untenanted do
post oauth_authorization_path, params: {
diff --git a/test/integration/oauth_flow_test.rb b/test/integration/oauth_flow_test.rb
index 664d623a6d..ab4fbeab50 100644
--- a/test/integration/oauth_flow_test.rb
+++ b/test/integration/oauth_flow_test.rb
@@ -56,6 +56,25 @@ class OauthFlowTest < ActionDispatch::IntegrationTest
}
end
+ assert_response :bad_request
+ assert_select "h2", text: "Can't authorize this app"
+ assert_select "p", text: "Unknown client"
+ assert_select "code", text: "invalid_request"
+ end
+
+ test "authorization answers an invalid client_id as JSON when JSON is asked for" do
+ sign_in_as :david
+
+ untenanted do
+ get new_oauth_authorization_path(format: :json), 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
@@ -367,8 +386,8 @@ class OauthFlowTest < ActionDispatch::IntegrationTest
get_consent_screen scope: "read write"
assert_response :success
- assert_select "select[name=scope] option[selected]", count: 1
- assert_select "select[name=scope] option[selected][value=?]", "read write"
+ assert_select "input[type=radio][name=scope][checked]", count: 1
+ assert_select "input[type=radio][name=scope][checked][value=?]", "read write"
end
test "authorization preselects Read + Write when the client requests write" do
@@ -377,7 +396,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest
get_consent_screen scope: "write"
assert_response :success
- assert_select "select[name=scope] option[selected][value=?]", "read write"
+ assert_select "input[type=radio][name=scope][checked][value=?]", "read write"
end
test "authorization preselects Read for a read request" do
@@ -386,7 +405,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest
get_consent_screen scope: "read"
assert_response :success
- assert_select "select[name=scope] option[selected][value=?]", "read"
+ assert_select "input[type=radio][name=scope][checked][value=?]", "read"
end
test "authorization offers no write option to a read-only client" do
@@ -396,8 +415,8 @@ class OauthFlowTest < ActionDispatch::IntegrationTest
get_consent_screen client: client, scope: "read"
assert_response :success
- assert_select "select[name=scope] option", count: 1
- assert_select "select[name=scope] option[value=?]", "read"
+ assert_select "input[type=radio][name=scope]", count: 1
+ assert_select "input[type=radio][name=scope][value=?]", "read"
end
test "consent grants the canonical read write scope" do
diff --git a/test/integration/oauth_instrumentation_test.rb b/test/integration/oauth_instrumentation_test.rb
index a0033bd27a..39d62ae9eb 100644
--- a/test/integration/oauth_instrumentation_test.rb
+++ b/test/integration/oauth_instrumentation_test.rb
@@ -62,6 +62,17 @@ class OauthInstrumentationTest < ActionDispatch::IntegrationTest
assert_equal [ { endpoint: "tokens", error_code: "invalid_grant", client_type: "unknown" } ], payloads
end
+ test "an authorization error shown on the error page is counted too" do
+ sign_in_as :david
+
+ payloads = notifications("error.oauth") do
+ untenanted { get new_oauth_authorization_path, params: { client_id: "nobody_#{SecureRandom.hex}", response_type: "code" } }
+ end
+
+ assert_response :bad_request
+ assert_equal [ { endpoint: "authorizations", error_code: "invalid_request", client_type: "unknown" } ], payloads
+ end
+
test "a rejected registration is a clients error" do
payloads = notifications("error.oauth") do
untenanted { post oauth_clients_path, as: :json, params: { redirect_uris: %w[ http://example.com/callback ] } }
diff --git a/test/models/oauth/client_test.rb b/test/models/oauth/client_test.rb
index 89eca65cf6..f10d6b7c20 100644
--- a/test/models/oauth/client_test.rb
+++ b/test/models/oauth/client_test.rb
@@ -626,6 +626,15 @@ class Oauth::ClientTest < ActiveSupport::TestCase
assert_not Oauth::Client.exists?(client.id)
end
+ test "connection_kind tells our apps, tools on this device and web apps apart" do
+ assert_equal :fizzy, oauth_clients(:trusted_client).connection_kind
+ assert_equal :local, oauth_clients(:mcp_client).connection_kind
+ assert_equal :web, oauth_clients(:confidential_client).connection_kind
+ assert_equal :local, Oauth::Client.new(redirect_uris: %w[ com.example.app:/callback http://[::1]/cb ]).connection_kind
+ assert_equal :web, Oauth::Client.new(redirect_uris: %w[ http://127.0.0.1/cb https://example.com/cb ]).connection_kind
+ assert_equal :local, Oauth::Client.new(redirect_uris: %w[ https://127.0.0.1/cb https://localhost:8443/cb ]).connection_kind
+ end
+
private
def register_metadata_document_client(**attributes)
Oauth::Client.create! name: "Claude", client_id: "https://claude.ai/client.json", redirect_uris: %w[ https://claude.ai/callback ], client_id_metadata_document: true, **attributes