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: "Use a different account?" 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 "[data-oauth-error=?]", "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