Ctrl K
ComunidadGuías…

test/integration/oauth_flow_test.rb

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
require "test_helper"

class OauthFlowTest < ActionDispatch::IntegrationTest
  include OauthClientCredentialsTestHelper

  # 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_select "h2", text: "Can't authorize this app"
    assert_select "p", text: Oauth::AuthorizationsController::UNKNOWN_CLIENT_MESSAGE
    assert_select "[data-oauth-error=?]", "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

  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 "[data-oauth-error=?]", "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 = URI.decode_www_form(URI.parse(response.location).query).to_h
    assert_equal "invalid_request", redirect_params["error"]
    assert_match "code_challenge", redirect_params["error_description"]
  end

  test "authorization errors for a self-registered https client render here instead of redirecting" do
    sign_in_as :david
    client = Oauth::Client.create!(name: "Hosted", redirect_uris: %w[ https://connector.example.com/callback ], dynamically_registered: true)

    get_consent_screen client: client, code_challenge: nil

    assert_response :bad_request
    assert_select "[data-oauth-error=?]", "invalid_request"
  end

  test "no pre-consent error redirects to a self-registered https host" do
    sign_in_as :david
    client = Oauth::Client.create!(name: "Hosted", redirect_uris: %w[ https://connector.example.com/callback ], dynamically_registered: true)

    {
      { scope: "admin" } => "invalid_scope",
      { state: "s" * 2049 } => "invalid_request",
      { response_type: "token" } => "unsupported_response_type",
      { code_challenge_method: "plain" } => "invalid_request"
    }.each do |overrides, error|
      get_consent_screen client: client, **overrides

      assert_response :bad_request, "#{overrides} should not redirect"
      assert_nil response.location
      assert_select "[data-oauth-error=?]", error
    end
  end

  test "pre-consent errors still redirect to an operator-provisioned https client" do
    sign_in_as :david

    get_consent_screen client: oauth_clients(:trusted_client), scope: "admin"

    assert_response :redirect
    assert_match %r{\Ahttps://app\.example\.com/oauth/callback\?}, response.location
    assert_equal "invalid_scope", Rack::Utils.parse_query(URI.parse(response.location).query)["error"]
  end

  test "authorization errors for a self-registered loopback client still redirect" do
    sign_in_as :david

    get_consent_screen code_challenge: nil

    assert_response :redirect
    assert_equal "invalid_request", Rack::Utils.parse_query(URI.parse(response.location).query)["error"]
  end

  test "consent names a self-registered redirect's port, when it isn't the default, as where the name was set" do
    sign_in_as :david
    client = Oauth::Client.create!(name: "Hosted", redirect_uris: %w[ https://connector.example.com:8443/callback ], dynamically_registered: true)

    get_consent_screen client: client

    assert_response :success
    assert_select ".oauth-client__byline", text: /\AName set by the app at connector\.example\.com:8443\z/
    assert_equal 1, response.parsed_body.at("body").text.scan("connector.example.com:8443").size
  end

  test "consent restarts a self-registered client's retention period, so the sweep spares it while the code is live" do
    sign_in_as :david
    client = travel_to(31.days.ago) { Oauth::Client.create!(name: "Returning", redirect_uris: %w[ http://127.0.0.1:8888/callback ], dynamically_registered: true) }
    assert_includes Oauth::Client.stale, client

    post_consent client: client

    assert_response :redirect
    assert_not_includes Oauth::Client.stale, client
  end

  test "consent takes a dynamic registration off the one-day retention for registrations nobody authorized" do
    sign_in_as :david
    client = travel_to(25.hours.ago) { Oauth::Client.create!(name: "Fresh", redirect_uris: %w[ http://127.0.0.1:8888/callback ], dynamically_registered: true) }
    assert_includes Oauth::Client.stale, client

    post_consent client: client

    assert_response :redirect
    assert_not_includes Oauth::Client.stale, client
  end

  test "consent issues no code for a client swept while consent was submitted" do
    sign_in_as :david
    client = Oauth::Client.create!(name: "Swept", redirect_uris: %w[ http://127.0.0.1:8888/callback ], dynamically_registered: true)

    # The sweep deletes the client between consent reading it and touching it.
    Oauth::Client.any_instance.stubs(:allows_redirect?).with { client.delete }.returns(true)
    Oauth::AuthorizationCode.expects(:generate).never

    post_consent client: client

    assert_response :bad_request
  end

  test "denying consent leaves a self-registered client's retention period alone" do
    sign_in_as :david
    client = travel_to(31.days.ago) { Oauth::Client.create!(name: "Returning", redirect_uris: %w[ http://127.0.0.1:8888/callback ], dynamically_registered: true) }

    post_consent client: client, error: "access_denied"

    assert_includes Oauth::Client.stale, client
  end

  test "denying a self-registered https client still redirects, after the consent screen named its host" do
    sign_in_as :david
    client = Oauth::Client.create!(name: "Hosted", redirect_uris: %w[ https://connector.example.com/callback ], dynamically_registered: true)

    post_consent client: client, error: "access_denied"

    assert_response :redirect
    assert_match %r{\Ahttps://connector\.example\.com/callback\?}, response.location
    assert_equal "access_denied", Rack::Utils.parse_query(URI.parse(response.location).query)["error"]
  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 = URI.decode_www_form(redirect_uri.query).to_h
    assert_not_nil params["code"]
    assert_equal "xyz123", params["state"]
  end


  test "authorization consent issues a code without state, and returns none" 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"
      }
    end

    assert_response :success

    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: ""
      }
    end

    assert_response :redirect
    params = URI.decode_www_form(URI.parse(response.location).query).to_h
    assert_not_nil params["code"]
    assert_not params.key?("state")
  end

  test "authorization echoes a state of the maximum length and refuses a longer one" do
    sign_in_as :david
    client = oauth_clients(:mcp_client)
    authorization = {
      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"
    }

    untenanted do
      get new_oauth_authorization_path, params: authorization.merge(state: "s" * 2048)
    end

    assert_response :success

    untenanted do
      post oauth_authorization_path, params: authorization.merge(state: "s" * 2048)
    end

    assert_response :redirect
    assert_equal "s" * 2048, URI.decode_www_form(URI.parse(response.location).query).to_h["state"]

    untenanted do
      get new_oauth_authorization_path, params: authorization.merge(state: "s" * 2049)
    end

    assert_response :redirect
    params = URI.decode_www_form(URI.parse(response.location).query).to_h
    assert_equal "invalid_request", params["error"]
    assert_not params.key?("code")
  end

  test "an authorization request too long to keep in the session still asks the person to sign in" 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: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
        code_challenge_method: "S256",
        state: "s" * 2048
      }
    end

    assert_response :redirect
    assert_match %r{/session/new}, response.location
    assert_nil session[:return_to_after_authenticating]
  end

  test "authorization refuses a code_challenge that isn't an S256 challenge" do
    sign_in_as :david
    client = oauth_clients(:mcp_client)

    [ [ "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" ], "too-short", "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM=" ].each do |code_challenge|
      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"
        }
      end

      assert_response :redirect
      params = URI.decode_www_form(URI.parse(response.location).query).to_h
      assert_equal "invalid_request", params["error"], code_challenge.inspect
      assert_not params.key?("code")
    end
  end

  test "authorization refuses a state that isn't a single string" 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: [ "a", "b" ]
      }
    end

    assert_response :redirect
    assert_equal "invalid_request", URI.decode_www_form(URI.parse(response.location).query).to_h["error"]
  end

  test "authorization preselects Read + Write when the client requests read write" do
    sign_in_as :david

    get_consent_screen scope: "read write"

    assert_response :success
    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
    sign_in_as :david

    get_consent_screen scope: "write"

    assert_response :success
    assert_select "input[type=radio][name=scope][checked][value=?]", "read write"
  end

  test "authorization preselects Read for a read request" do
    sign_in_as :david

    get_consent_screen scope: "read"

    assert_response :success
    assert_select "input[type=radio][name=scope][checked][value=?]", "read"
  end

  test "authorization offers no write option to a read-only client" do
    sign_in_as :david
    client = Oauth::Client.create!(name: "Reader", redirect_uris: %w[ http://127.0.0.1:8888/callback ], scopes: %w[ read ], dynamically_registered: true)

    get_consent_screen client: client, scope: "read"

    assert_response :success
    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
    sign_in_as :david
    code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"

    post_consent scope: "read write", code_challenge: Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
    code = Rack::Utils.parse_query(URI.parse(response.location).query)["code"]

    untenanted do
      post oauth_token_path, params: {
        grant_type: "authorization_code",
        client_id: oauth_clients(:mcp_client).client_id,
        code: code,
        redirect_uri: "http://127.0.0.1:8888/callback",
        code_verifier: code_verifier
      }, as: :json
    end

    assert_response :success
    assert_equal "read write", response.parsed_body["scope"]
    assert_equal "write", Identity::AccessToken.find_by_raw_token(response.parsed_body["access_token"]).permission
  end

  test "authorization redirects carry the issuer from the metadata (RFC 9207)" do
    sign_in_as :david

    untenanted { get "/.well-known/oauth-authorization-server" }
    issuer = response.parsed_body["issuer"]
    assert_predicate issuer, :present?

    post_consent
    assert_equal issuer, Rack::Utils.parse_query(URI.parse(response.location).query)["iss"]

    post_consent error: "access_denied"
    denied = Rack::Utils.parse_query(URI.parse(response.location).query)
    assert_equal "access_denied", denied["error"]
    assert_equal issuer, denied["iss"]

    get_consent_screen code_challenge: nil
    assert_response :redirect
    invalid = Rack::Utils.parse_query(URI.parse(response.location).query)
    assert_equal "invalid_request", invalid["error"]
    assert_equal issuer, invalid["iss"]
  end

  test "authorization redirect keeps the registered query alongside iss" do
    sign_in_as :david
    client = Oauth::Client.create!(name: "Query", redirect_uris: %w[ https://app.example.com/cb?tenant=acme ], scopes: %w[ read ])

    post_consent client: client, redirect_uri: "https://app.example.com/cb?tenant=acme"

    query = Rack::Utils.parse_query(URI.parse(response.location).query)
    assert_equal "acme", query["tenant"]
    assert_equal "http://www.example.com", query["iss"]
  end

  test "consent screen lets the form redirect to the validated loopback origin" do
    sign_in_as :david

    get_consent_screen redirect_uri: "http://127.0.0.1:53682/callback"

    assert_response :success
    assert_equal [ "'self'", "http://127.0.0.1:53682" ], form_action_sources
  end

  test "consent screen lets the form redirect to the validated https origin" do
    sign_in_as :david

    get_consent_screen client: oauth_clients(:trusted_client), redirect_uri: "https://app.example.com/oauth/callback"

    assert_response :success
    assert_equal [ "'self'", "https://app.example.com" ], form_action_sources
  end

  test "consent screen allows only the http scheme for an IPv6 loopback, which CSP cannot name" do
    sign_in_as :david
    client = Oauth::Client.create!(name: "IPv6", redirect_uris: %w[ http://[::1]:8888/callback ], dynamically_registered: true)

    get_consent_screen client: client, redirect_uri: "http://[::1]:9999/callback"

    assert_response :success
    assert_equal [ "'self'", "http:" ], form_action_sources
  end

  test "an unvalidated redirect_uri adds nothing to form-action" do
    sign_in_as :david

    get_consent_screen redirect_uri: "http://evil.com/steal"

    assert_response :bad_request
    assert_equal [ "'self'" ], form_action_sources
  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",
          client_id: oauth_clients(:mcp_client).client_id,
          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 body["access_token"].start_with?("fizzy_at_")
    assert body["refresh_token"].start_with?("fizzy_rt_")
    assert_equal 3600, body["expires_in"], "a fresh 1-hour token reports the whole hour"
    assert_equal "Bearer", body["token_type"]
    assert_equal "read", body["scope"]

    token = Identity::AccessToken.find_by_raw_token(body["access_token"])
    assert_equal client, token.oauth_client
    assert_equal identity, token.identity
    assert_equal "read", token.permission
  end

  test "token exchange reports a legacy write scope canonically" do
    code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
    client = oauth_clients(:mcp_client)

    untenanted do
      post oauth_token_path, params: {
        grant_type: "authorization_code",
        client_id: client.client_id,
        code: authorization_code_for(client, code_verifier: code_verifier, scope: "write"),
        redirect_uri: "http://127.0.0.1:8888/callback",
        code_verifier: code_verifier
      }, as: :json
    end

    assert_response :success
    assert_equal "read write", response.parsed_body["scope"]
  end

  test "token exchange issues nothing for a client swept after the exchange read it" do
    code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
    client = oauth_clients(:mcp_client)
    code = authorization_code_for(client, code_verifier: code_verifier)

    # The sweep deletes the client between the exchange's lookup and its issuance.
    Oauth::AuthorizationCode.stubs(:valid_pkce?).with { client.delete }.returns(true)

    assert_no_difference "Identity::AccessToken.count" do
      untenanted do
        post oauth_token_path, params: {
          grant_type: "authorization_code",
          client_id: client.client_id,
          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 invalid code" do
    untenanted do
      post oauth_token_path, params: {
        grant_type: "authorization_code",
        client_id: oauth_clients(:mcp_client).client_id,
        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",
        client_id: oauth_clients(:mcp_client).client_id,
        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",
        client_id: oauth_clients(:mcp_client).client_id,
        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",
          client_id: oauth_clients(:mcp_client).client_id,
          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

  test "token exchange requires grant_type" do
    untenanted do
      post oauth_token_path, params: { code: "code" }, as: :json
    end

    assert_response :bad_request
    assert_equal "invalid_request", response.parsed_body["error"]
  end

  test "token exchange requires code, code_verifier, redirect_uri and client_id" do
    code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
    client = oauth_clients(:mcp_client)
    code = authorization_code_for(client, code_verifier: code_verifier)
    complete = { grant_type: "authorization_code", code: code, code_verifier: code_verifier,
      redirect_uri: "http://127.0.0.1:8888/callback", client_id: client.client_id }

    %i[ code code_verifier redirect_uri client_id ].each do |name|
      assert_no_difference "Identity::AccessToken.count" do
        untenanted { post oauth_token_path, params: complete.except(name), as: :json }
      end

      assert_response :bad_request
      assert_equal "invalid_request", response.parsed_body["error"], "missing #{name}"
      assert_match name.to_s, response.parsed_body["error_description"]
    end
  end

  test "token exchange rejects non-string parameters as a malformed request" do
    code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
    client = oauth_clients(:mcp_client)
    complete = { grant_type: "authorization_code", code_verifier: code_verifier,
      redirect_uri: "http://127.0.0.1:8888/callback", client_id: client.client_id }

    %i[ code code_verifier redirect_uri client_id ].each do |name|
      request = complete.merge(code: authorization_code_for(client, code_verifier: code_verifier))

      [ [ request[name] ], { "value" => request[name] }, 1 ].each do |malformed|
        assert_no_difference "Identity::AccessToken.count" do
          untenanted { post oauth_token_path, params: request.merge(name => malformed), as: :json }
        end

        assert_response :bad_request
        assert_equal "invalid_request", response.parsed_body["error"], "#{name} as #{malformed.class}"
        assert_match name.to_s, response.parsed_body["error_description"]
      end
    end
  end

  test "token exchange rejects a code issued to another client" do
    code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
    code = authorization_code_for(oauth_clients(:mcp_client), code_verifier: code_verifier)

    assert_no_difference "Identity::AccessToken.count" do
      untenanted do
        post oauth_token_path, params: {
          grant_type: "authorization_code",
          client_id: oauth_clients(:trusted_client).client_id,
          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 a reused code and revokes the grant it issued" do
    code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
    client = oauth_clients(:mcp_client)
    code = authorization_code_for(client, code_verifier: code_verifier)
    exchange = { grant_type: "authorization_code", client_id: client.client_id, code: code,
      redirect_uri: "http://127.0.0.1:8888/callback", code_verifier: code_verifier }

    untenanted { post oauth_token_path, params: exchange, as: :json }
    assert_response :success
    first_token = response.parsed_body["access_token"]
    assert Identity::AccessToken.exists?(token_digest: TokenDigester.digest(first_token))

    assert_difference "Identity::AccessToken.count", -1 do
      untenanted { post oauth_token_path, params: exchange, as: :json }
    end

    assert_response :bad_request
    assert_equal "invalid_grant", response.parsed_body["error"]
    assert_not Identity::AccessToken.exists?(token_digest: TokenDigester.digest(first_token))

    fresh_code = authorization_code_for(client, code_verifier: code_verifier)
    untenanted { post oauth_token_path, params: exchange.merge(code: fresh_code), as: :json }
    assert_response :success
  end

  test "a code replayed again after its grant was revoked mints nothing" do
    code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
    client = oauth_clients(:mcp_client)
    exchange = { grant_type: "authorization_code", client_id: client.client_id, code: authorization_code_for(client, code_verifier: code_verifier),
      redirect_uri: "http://127.0.0.1:8888/callback", code_verifier: code_verifier }

    untenanted { post oauth_token_path, params: exchange, as: :json }
    assert_response :success

    untenanted { post oauth_token_path, params: exchange, as: :json }
    assert_equal "invalid_grant", response.parsed_body["error"]

    assert_no_difference "Identity::AccessToken.count" do
      untenanted { post oauth_token_path, params: exchange, as: :json }
    end
    assert_response :bad_request
    assert_equal "invalid_grant", response.parsed_body["error"]
  end

  test "a code redeemed before redemptions were recorded is remembered when it's replayed" do
    code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
    client = oauth_clients(:mcp_client)
    code = authorization_code_for(client, code_verifier: code_verifier)
    exchange = { grant_type: "authorization_code", client_id: client.client_id, code: code,
      redirect_uri: "http://127.0.0.1:8888/callback", code_verifier: code_verifier }
    identities(:david).access_tokens.create!(oauth_client: client, authorization_code_jti: Oauth::AuthorizationCode.parse(code).jti)

    untenanted { post oauth_token_path, params: exchange, as: :json }
    assert_equal "invalid_grant", response.parsed_body["error"]

    assert_no_difference "Identity::AccessToken.count" do
      untenanted { post oauth_token_path, params: exchange, as: :json }
    end
    assert_equal "invalid_grant", response.parsed_body["error"]
  end

  test "a code can't be redeemed again once the person disconnects the app" do
    code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
    client = oauth_clients(:mcp_client)
    exchange = { grant_type: "authorization_code", client_id: client.client_id, code: authorization_code_for(client, code_verifier: code_verifier),
      redirect_uri: "http://127.0.0.1:8888/callback", code_verifier: code_verifier }

    untenanted { post oauth_token_path, params: exchange, as: :json }
    assert_response :success
    identities(:david).access_tokens.oauth.where(oauth_client: client).destroy_all

    assert_no_difference "Identity::AccessToken.count" do
      untenanted { post oauth_token_path, params: exchange, as: :json }
    end
    assert_response :bad_request
    assert_equal "invalid_grant", response.parsed_body["error"]
  end

  test "a replayed code that fails PKCE does not revoke the grant" do
    code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
    client = oauth_clients(:mcp_client)
    code = authorization_code_for(client, code_verifier: code_verifier)
    exchange = { grant_type: "authorization_code", client_id: client.client_id, code: code,
      redirect_uri: "http://127.0.0.1:8888/callback", code_verifier: code_verifier }

    untenanted { post oauth_token_path, params: exchange, as: :json }
    assert_response :success
    first_token = response.parsed_body["access_token"]

    untenanted { post oauth_token_path, params: exchange.merge(code_verifier: "wrong_verifier"), as: :json }
    assert_response :bad_request
    assert Identity::AccessToken.exists?(token_digest: TokenDigester.digest(first_token))
  end


  test "token endpoint accepts form-encoded requests with forgery protection active" do
    code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
    code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)

    code = Oauth::AuthorizationCode.generate \
      client_id: oauth_clients(:mcp_client).client_id,
      identity_id: identities(:david).id,
      code_challenge: code_challenge,
      redirect_uri: "http://127.0.0.1:8888/callback",
      scope: "read"

    with_forgery_protection do
      untenanted do
        post oauth_token_path, params: {
          grant_type: "authorization_code",
          client_id: oauth_clients(:mcp_client).client_id,
          code: code,
          redirect_uri: "http://127.0.0.1:8888/callback",
          code_verifier: code_verifier
        }, headers: { "X-Forwarded-Proto" => "https" }
      end
    end

    assert_response :success
    assert_not_nil response.parsed_body["access_token"]
  end


  # Confidential Clients (client_secret_post)

  test "token exchange for confidential client requires client secret" do
    client = oauth_clients(:confidential_client)
    code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
    code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)

    code = Oauth::AuthorizationCode.generate \
      client_id: client.client_id,
      identity_id: identities(:david).id,
      code_challenge: code_challenge,
      redirect_uri: "https://connector.example.com/callback",
      scope: "read"

    untenanted do
      post oauth_token_path, params: {
        grant_type: "authorization_code",
        client_id: client.client_id,
        code: code,
        redirect_uri: "https://connector.example.com/callback",
        code_verifier: code_verifier
      }, as: :json
    end

    assert_client_authentication_failed basic: false

    # Naming no client at all is a malformed request, whatever the code.
    untenanted do
      post oauth_token_path, params: {
        grant_type: "authorization_code",
        code: code,
        redirect_uri: "https://connector.example.com/callback",
        code_verifier: code_verifier
      }, as: :json
    end

    assert_response :bad_request
    assert_equal "invalid_request", response.parsed_body["error"]
  end

  test "token exchange for confidential client rejects a wrong client secret" do
    client = oauth_clients(:confidential_client)
    code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
    code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)

    code = Oauth::AuthorizationCode.generate \
      client_id: client.client_id,
      identity_id: identities(:david).id,
      code_challenge: code_challenge,
      redirect_uri: "https://connector.example.com/callback",
      scope: "read"

    untenanted do
      post oauth_token_path, params: {
        grant_type: "authorization_code",
        code: code,
        redirect_uri: "https://connector.example.com/callback",
        code_verifier: code_verifier,
        client_secret: "wrong"
      }, as: :json
    end

    assert_client_authentication_failed basic: false
  end

  test "token exchange for confidential client rejects a non-string client secret" do
    client = oauth_clients(:confidential_client)
    code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
    code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)

    code = Oauth::AuthorizationCode.generate \
      client_id: client.client_id,
      identity_id: identities(:david).id,
      code_challenge: code_challenge,
      redirect_uri: "https://connector.example.com/callback",
      scope: "read"

    untenanted do
      post oauth_token_path, params: {
        grant_type: "authorization_code",
        code: code,
        redirect_uri: "https://connector.example.com/callback",
        code_verifier: code_verifier,
        client_secret: [ "confidential_secret_789" ]
      }, as: :json
    end

    assert_client_authentication_failed basic: false
  end

  test "token exchange for confidential client succeeds with the client secret" do
    client = oauth_clients(:confidential_client)
    code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
    code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)

    code = Oauth::AuthorizationCode.generate \
      client_id: client.client_id,
      identity_id: identities(:david).id,
      code_challenge: code_challenge,
      redirect_uri: "https://connector.example.com/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: "https://connector.example.com/callback",
          code_verifier: code_verifier,
          client_id: client.client_id,
          client_secret: "confidential_secret_789"
        }, as: :json
      end
    end

    assert_response :success
    assert_not_nil response.parsed_body["access_token"]
  end

  test "token exchange for confidential client rejects credentials in the query string" do
    client = oauth_clients(:confidential_client)
    code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
    code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)

    code = Oauth::AuthorizationCode.generate \
      client_id: client.client_id,
      identity_id: identities(:david).id,
      code_challenge: code_challenge,
      redirect_uri: "https://connector.example.com/callback",
      scope: "read"

    untenanted do
      post oauth_token_path(client_id: client.client_id, client_secret: "confidential_secret_789"), params: {
        grant_type: "authorization_code",
        code: code,
        redirect_uri: "https://connector.example.com/callback",
        code_verifier: code_verifier
      }, as: :json
    end

    assert_client_authentication_failed basic: false
  end

  test "token exchange for confidential client requires a matching client_id" do
    client = oauth_clients(:confidential_client)
    code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
    code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)

    code = Oauth::AuthorizationCode.generate \
      client_id: client.client_id,
      identity_id: identities(:david).id,
      code_challenge: code_challenge,
      redirect_uri: "https://connector.example.com/callback",
      scope: "read"

    untenanted do
      post oauth_token_path, params: {
        grant_type: "authorization_code",
        code: code,
        redirect_uri: "https://connector.example.com/callback",
        code_verifier: code_verifier,
        client_secret: "confidential_secret_789"
      }, as: :json
    end

    assert_client_authentication_failed basic: false
  end

  test "refresh grant for confidential client requires the client secret" do
    client = oauth_clients(:confidential_client)
    token = identities(:david).access_tokens.create!(oauth_client: client)

    untenanted do
      post oauth_token_path, params: {
        grant_type: "refresh_token",
        refresh_token: token.raw_refresh_token,
        client_id: client.client_id
      }, as: :json
    end

    assert_client_authentication_failed basic: false

    untenanted do
      post oauth_token_path, params: {
        grant_type: "refresh_token",
        refresh_token: token.raw_refresh_token,
        client_id: client.client_id,
        client_secret: "confidential_secret_789"
      }, as: :json
    end

    assert_response :success
  end

  test "code grant for confidential client with a missing or wrong client_id fails as invalid_client" do
    code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
    client = oauth_clients(:confidential_client)
    exchange = { grant_type: "authorization_code", client_secret: "confidential_secret_789",
      code: authorization_code_for(client, code_verifier: code_verifier),
      redirect_uri: "http://127.0.0.1:8888/callback", code_verifier: code_verifier }

    [ {}, { client_id: oauth_clients(:mcp_client).client_id } ].each do |client_id|
      assert_no_difference "Identity::AccessToken.count" do
        untenanted { post oauth_token_path, params: exchange.merge(client_id), as: :json }
      end

      assert_client_authentication_failed basic: false
    end
  end

  test "code grant for confidential client authenticates before checking the verifier or redirect_uri" do
    code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
    client = oauth_clients(:confidential_client)
    exchange = { grant_type: "authorization_code", client_id: client.client_id, client_secret: "wrong",
      code: authorization_code_for(client, code_verifier: code_verifier),
      redirect_uri: "http://127.0.0.1:8888/callback", code_verifier: code_verifier }

    [ { code_verifier: "not-the-verifier" }, { redirect_uri: "http://127.0.0.1:8888/elsewhere" } ].each do |mismatch|
      untenanted { post oauth_token_path, params: exchange.merge(mismatch), as: :json }

      assert_client_authentication_failed basic: false
    end
  end

  test "token errors are never cached" do
    client = oauth_clients(:confidential_client)
    token = identities(:david).access_tokens.create!(oauth_client: client)

    { { grant_type: "password" } => :bad_request,
      { grant_type: "refresh_token", refresh_token: token.raw_refresh_token, client_id: client.client_id, client_secret: "wrong" } => :bad_request,
      { grant_type: "refresh_token", refresh_token: "bogus", client_id: oauth_clients(:mcp_client).client_id } => :bad_request }.each do |request, status|
      untenanted { post oauth_token_path, params: request, as: :json }

      assert_response status
      assert_equal "no-store", response.headers["Cache-Control"], request.inspect
      assert_equal "no-cache", response.headers["Pragma"]
    end
  end

  test "registration errors are never cached" do
    untenanted { post oauth_clients_path, params: { redirect_uris: [] }, as: :json }

    assert_response :bad_request
    assert_equal "no-store", response.headers["Cache-Control"]
  end

  test "confidential client authentication does not reveal whether a refresh token is live" do
    client = oauth_clients(:confidential_client)
    token = identities(:david).access_tokens.create!(oauth_client: client)

    [ token.raw_refresh_token, "not-a-refresh-token" ].each do |refresh_token|
      untenanted { post oauth_token_path, params: { grant_type: "refresh_token", refresh_token: refresh_token, client_id: client.client_id, client_secret: "wrong" }, as: :json }
      assert_equal "invalid_client", response.parsed_body["error"], "wrong secret"

      untenanted { post oauth_token_path, params: { grant_type: "refresh_token", refresh_token: refresh_token, client_id: oauth_clients(:mcp_client).client_id }, as: :json }
      assert_equal "invalid_grant", response.parsed_body["error"], "another client's id"

      untenanted { post oauth_token_path, params: { grant_type: "refresh_token", refresh_token: refresh_token }, as: :json }
      assert_equal "invalid_request", response.parsed_body["error"], "no client_id"
    end

    assert_equal token.refresh_token_digest, token.reload.refresh_token_digest
  end

  test "confidential client authentication does not reveal whether a code is valid" do
    client = oauth_clients(:confidential_client)
    code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"

    [ authorization_code_for(client, code_verifier: code_verifier), "not-a-code" ].each do |code|
      untenanted do
        post oauth_token_path, params: { grant_type: "authorization_code", client_id: client.client_id, client_secret: "wrong",
          code: code, redirect_uri: "http://127.0.0.1:8888/callback", code_verifier: code_verifier }, as: :json
      end

      assert_equal "invalid_client", response.parsed_body["error"]
    end
  end

  test "a grant issued to another client answers exactly as a dead one does" do
    live = identities(:david).access_tokens.create!(oauth_client: oauth_clients(:confidential_client))
    answers = [ live.raw_refresh_token, "no-such-refresh-token" ].map do |refresh_token|
      untenanted do
        post oauth_token_path, params: { grant_type: "refresh_token", refresh_token: refresh_token, client_id: oauth_clients(:mcp_client).client_id }
      end
      [ response.status, response.parsed_body ]
    end
    assert_equal answers.first, answers.last

    code = Oauth::AuthorizationCode.generate client_id: oauth_clients(:confidential_client).client_id, identity_id: identities(:david).id,
      code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", redirect_uri: "https://connector.example.com/callback", scope: "read"
    answers = [ code, "no-such-code" ].map do |presented|
      untenanted do
        post oauth_token_path, params: { grant_type: "authorization_code", code: presented, client_id: oauth_clients(:mcp_client).client_id,
          redirect_uri: "https://connector.example.com/callback", code_verifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" }
      end
      [ response.status, response.parsed_body ]
    end
    assert_equal answers.first, answers.last
  end

  test "refresh grant for confidential client omitting client_id fails as invalid_client" do
    client = oauth_clients(:confidential_client)
    token = identities(:david).access_tokens.create!(oauth_client: client)

    untenanted do
      post oauth_token_path, params: {
        grant_type: "refresh_token",
        refresh_token: token.raw_refresh_token,
        client_secret: "confidential_secret_789"
      }, as: :json
    end

    assert_client_authentication_failed basic: false
  end


  # Refresh Grant

  test "refresh grant rotates access and refresh tokens" do
    client = oauth_clients(:mcp_client)
    token = identities(:david).access_tokens.create!(oauth_client: client)
    old_access_token, old_refresh_token = token.raw_token, token.raw_refresh_token

    assert_no_difference "Identity::AccessToken.count" do
      untenanted do
        post oauth_token_path, params: {
          grant_type: "refresh_token",
          refresh_token: old_refresh_token,
          client_id: client.client_id
        }, as: :json
      end
    end

    assert_response :success
    body = response.parsed_body

    assert_not_nil body["access_token"]
    assert_not_nil body["refresh_token"]
    assert_equal 3600, body["expires_in"], "a fresh 1-hour token reports the whole hour"
    assert_not_equal old_access_token, body["access_token"]
    assert_not_equal old_refresh_token, body["refresh_token"]
  end

  test "a grant issued before prefixes still authenticates, and refreshes into prefixed tokens" do
    client = oauth_clients(:mcp_client)
    grant = identities(:david).access_tokens.create!(oauth_client: client,
      raw_token: "legacyAccessToken0123456789", raw_refresh_token: "legacyRefreshToken0123456789")

    get user_path(users(:david)), env: { "HTTP_AUTHORIZATION" => "Bearer #{grant.raw_token}" }, as: :json
    assert_response :success

    untenanted do
      post oauth_token_path, params: { grant_type: "refresh_token", refresh_token: grant.raw_refresh_token, client_id: client.client_id }, as: :json
    end

    assert_response :success
    assert response.parsed_body["access_token"].start_with?("fizzy_at_")
    assert response.parsed_body["refresh_token"].start_with?("fizzy_rt_")
  end

  test "a personal access token issued before prefixes still authenticates" do
    get user_path(users(:david)), env: { "HTTP_AUTHORIZATION" => "Bearer #{identity_access_tokens(:davids_api_token).token}" }, as: :json

    assert_response :success
  end

  test "refresh grant for a token whose client is gone fails as invalid_grant" do
    client = oauth_clients(:mcp_client)
    token = identities(:david).access_tokens.create!(oauth_client: client)
    client.delete

    untenanted do
      post oauth_token_path, params: {
        grant_type: "refresh_token",
        refresh_token: token.raw_refresh_token,
        client_id: client.client_id
      }, as: :json
    end

    assert_response :bad_request
    assert_equal "invalid_grant", response.parsed_body["error"]
  end

  test "refresh grant requires refresh_token and client_id" do
    client = oauth_clients(:mcp_client)
    token = identities(:david).access_tokens.create!(oauth_client: client)
    complete = { grant_type: "refresh_token", refresh_token: token.raw_refresh_token, client_id: client.client_id }

    %i[ refresh_token client_id ].each do |name|
      untenanted { post oauth_token_path, params: complete.except(name), as: :json }

      assert_response :bad_request
      assert_equal "invalid_request", response.parsed_body["error"], "missing #{name}"
      assert_match name.to_s, response.parsed_body["error_description"]
    end

    assert_equal TokenDigester.digest(complete[:refresh_token]), token.reload.refresh_token_digest
  end

  test "refresh grant rejects non-string refresh_token and client_id as a malformed request" do
    client = oauth_clients(:mcp_client)
    token = identities(:david).access_tokens.create!(oauth_client: client)
    complete = { grant_type: "refresh_token", refresh_token: token.raw_refresh_token, client_id: client.client_id }

    %i[ refresh_token client_id ].each do |name|
      [ [ complete[name] ], { "value" => complete[name] }, 1 ].each do |malformed|
        untenanted { post oauth_token_path, params: complete.merge(name => malformed), as: :json }

        assert_response :bad_request
        assert_equal "invalid_request", response.parsed_body["error"], "#{name} as #{malformed.class}"
        assert_match name.to_s, response.parsed_body["error_description"]
      end
    end

    assert_equal TokenDigester.digest(complete[:refresh_token]), token.reload.refresh_token_digest
  end

  test "a reused code revokes the grant it issued, refreshed or not" do
    code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
    client = oauth_clients(:mcp_client)
    exchange = { grant_type: "authorization_code", client_id: client.client_id,
      code: authorization_code_for(client, code_verifier: code_verifier),
      redirect_uri: "http://127.0.0.1:8888/callback", code_verifier: code_verifier }

    untenanted { post oauth_token_path, params: exchange, as: :json }
    assert_response :success

    untenanted do
      post oauth_token_path, params: { grant_type: "refresh_token",
        refresh_token: response.parsed_body["refresh_token"], client_id: client.client_id }, as: :json
    end
    assert_response :success
    refreshed = response.parsed_body

    untenanted { post oauth_token_path, params: exchange, as: :json }
    assert_response :bad_request
    assert_equal "invalid_grant", response.parsed_body["error"]

    assert_not Identity::AccessToken.exists?(token_digest: TokenDigester.digest(refreshed["access_token"]))
    untenanted do
      post oauth_token_path, params: { grant_type: "refresh_token",
        refresh_token: refreshed["refresh_token"], client_id: client.client_id }, as: :json
    end
    assert_response :bad_request
    assert_equal "invalid_grant", response.parsed_body["error"]
  end

  test "refresh grant echoes the granted scope" do
    client = oauth_clients(:mcp_client)
    token = identities(:david).access_tokens.create!(oauth_client: client, permission: :write)

    untenanted do
      post oauth_token_path, params: {
        grant_type: "refresh_token",
        refresh_token: token.raw_refresh_token,
        client_id: client.client_id
      }, as: :json
    end

    assert_response :success
    assert_equal "read write", response.parsed_body["scope"]
  end

  test "code exchange and refresh report a write grant the same way" do
    client = oauth_clients(:mcp_client)
    code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"

    untenanted do
      post oauth_token_path, params: { grant_type: "authorization_code", client_id: client.client_id,
        code: authorization_code_for(client, code_verifier: code_verifier, scope: "read write"),
        redirect_uri: "http://127.0.0.1:8888/callback", code_verifier: code_verifier }, as: :json
    end
    assert_response :success
    assert_equal "read write", response.parsed_body["scope"]

    untenanted do
      post oauth_token_path, params: { grant_type: "refresh_token",
        refresh_token: response.parsed_body["refresh_token"], client_id: client.client_id }, as: :json
    end
    assert_response :success
    assert_equal "read write", response.parsed_body["scope"]
  end

  test "refresh grant narrows the token to a requested subset scope" do
    client = oauth_clients(:mcp_client)
    token = identities(:david).access_tokens.create!(oauth_client: client, permission: :write)

    untenanted do
      post oauth_token_path, params: {
        grant_type: "refresh_token",
        refresh_token: token.raw_refresh_token,
        client_id: client.client_id,
        scope: "read"
      }, as: :json
    end

    assert_response :success
    assert_equal "read", response.parsed_body["scope"]
    assert_equal "read", Identity::AccessToken.find_by_raw_token(response.parsed_body["access_token"]).permission
  end

  test "refresh grant rejects a scope broader than the original grant" do
    client = oauth_clients(:mcp_client)
    token = identities(:david).access_tokens.create!(oauth_client: client, permission: :read)
    old_refresh_token = token.raw_refresh_token

    untenanted do
      post oauth_token_path, params: {
        grant_type: "refresh_token",
        refresh_token: old_refresh_token,
        client_id: client.client_id,
        scope: "write"
      }, as: :json
    end

    assert_response :bad_request
    assert_equal "invalid_scope", response.parsed_body["error"]
    assert_equal TokenDigester.digest(old_refresh_token), token.reload.refresh_token_digest
  end

  test "refresh grant rejects a blank scope rather than restoring the full grant" do
    client = oauth_clients(:mcp_client)
    token = identities(:david).access_tokens.create!(oauth_client: client, permission: :write)
    old_refresh_token = token.raw_refresh_token

    untenanted do
      post oauth_token_path, params: {
        grant_type: "refresh_token",
        refresh_token: old_refresh_token,
        client_id: client.client_id,
        scope: " "
      }, as: :json
    end

    assert_response :bad_request
    assert_equal "invalid_scope", response.parsed_body["error"]
    assert_equal TokenDigester.digest(old_refresh_token), token.reload.refresh_token_digest
  end

  test "refresh grant rejects an explicit null scope rather than restoring the full grant" do
    client = oauth_clients(:mcp_client)
    token = identities(:david).access_tokens.create!(oauth_client: client, permission: :write)
    old_refresh_token = token.raw_refresh_token

    untenanted do
      post oauth_token_path, params: {
        grant_type: "refresh_token",
        refresh_token: old_refresh_token,
        client_id: client.client_id,
        scope: nil
      }, as: :json
    end

    assert_response :bad_request
    assert_equal "invalid_scope", response.parsed_body["error"]
    assert_equal TokenDigester.digest(old_refresh_token), token.reload.refresh_token_digest
  end

  test "refresh grant works after the access token expires" do
    client = oauth_clients(:mcp_client)
    token = identities(:david).access_tokens.create!(oauth_client: client)

    travel Identity::AccessToken::EXPIRES_IN + 1.minute do
      untenanted do
        post oauth_token_path, params: {
          grant_type: "refresh_token",
          refresh_token: token.raw_refresh_token,
          client_id: client.client_id
        }, as: :json
      end

      assert_response :success
      assert_not Identity::AccessToken.find_by_raw_token(response.parsed_body["access_token"]).expired?
    end
  end

  test "refresh grant invalidates the previous refresh token" do
    client = oauth_clients(:mcp_client)
    token = identities(:david).access_tokens.create!(oauth_client: client)
    old_refresh_token = token.raw_refresh_token

    untenanted do
      post oauth_token_path, params: {
        grant_type: "refresh_token",
        refresh_token: old_refresh_token,
        client_id: client.client_id
      }, as: :json
    end
    assert_response :success

    travel Oauth::RetiredRefreshToken::GRACE + 1.second
    untenanted do
      post oauth_token_path, params: {
        grant_type: "refresh_token",
        refresh_token: old_refresh_token,
        client_id: client.client_id
      }, as: :json
    end

    assert_response :bad_request
    assert_equal "invalid_grant", response.parsed_body["error"]
  end

  test "refresh grant rejects a client mismatch" do
    token = identities(:david).access_tokens.create!(oauth_client: oauth_clients(:mcp_client))

    untenanted do
      post oauth_token_path, params: {
        grant_type: "refresh_token",
        refresh_token: token.raw_refresh_token,
        client_id: oauth_clients(:trusted_client).client_id
      }, as: :json
    end

    assert_response :bad_request
    assert_equal "invalid_grant", response.parsed_body["error"]
  end

  test "refresh grant rejects unknown refresh token" do
    untenanted do
      post oauth_token_path, params: {
        grant_type: "refresh_token",
        refresh_token: "nonexistent",
        client_id: oauth_clients(:mcp_client).client_id
      }, as: :json
    end

    assert_response :bad_request
    assert_equal "invalid_grant", response.parsed_body["error"]
  end

  test "refresh grant rejects blank refresh token as a malformed request" do
    untenanted do
      post oauth_token_path, params: {
        grant_type: "refresh_token",
        refresh_token: "",
        client_id: oauth_clients(:mcp_client).client_id
      }, as: :json
    end

    assert_response :bad_request
    assert_equal "invalid_request", response.parsed_body["error"]
  end


  # Token Revocation (RFC 7009)

  test "revocation deletes access token" do
    token = identities(:david).access_tokens.create!(oauth_client: oauth_clients(:mcp_client))

    assert_difference "Identity::AccessToken.count", -1 do
      untenanted do
        post oauth_revocation_path, params: { token: token.raw_token, client_id: token.oauth_client.client_id }, as: :json
      end
    end

    assert_response :success
  end

  test "revocation by refresh token revokes the grant" do
    token = identities(:david).access_tokens.create!(oauth_client: oauth_clients(:mcp_client))

    assert_difference "Identity::AccessToken.count", -1 do
      untenanted do
        post oauth_revocation_path, params: { token: token.raw_refresh_token, client_id: token.oauth_client.client_id }, 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
    assert_equal "invalid_request", response.parsed_body["error"]
  end

  test "revocation without a token answers invalid_request, form-encoded or JSON" do
    untenanted do
      post oauth_revocation_path, params: { token_type_hint: "access_token" }
    end
    assert_response :bad_request
    assert_equal "invalid_request", response.parsed_body["error"]

    untenanted do
      post oauth_revocation_path, params: {}, as: :json
    end
    assert_response :bad_request
    assert_equal "invalid_request", response.parsed_body["error"]
  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"], "the issuer is the origin, with no trailing slash"
    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"
    assert_equal true, body["authorization_response_iss_parameter_supported"]
    assert_equal %w[ query ], body["response_modes_supported"]
    assert_match %r{/oauth/revocation$}, body["revocation_endpoint"]
    assert_equal %w[ none client_secret_post client_secret_basic ], body["revocation_endpoint_auth_methods_supported"]
    assert_includes body["grant_types_supported"], "authorization_code"
    assert_includes body["grant_types_supported"], "refresh_token"
    assert_equal %w[ none client_secret_post client_secret_basic ], body["token_endpoint_auth_methods_supported"]
  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_equal [ "http://www.example.com" ], body["authorization_servers"]
  end

  test "the protected resource names exactly the issuer the authorization server advertises" do
    untenanted { get "/.well-known/oauth-authorization-server" }
    issuer = response.parsed_body["issuer"]

    untenanted { get "/.well-known/oauth-protected-resource" }
    assert_equal [ issuer ], response.parsed_body["authorization_servers"]
  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"]
    assert_equal %w[ authorization_code refresh_token ], body["grant_types"]
  end

  test "DCR registering write also registers read, which write implies" do
    untenanted do
      post oauth_clients_path, params: {
        client_name: "Writer",
        redirect_uris: [ "http://127.0.0.1:8888/callback" ],
        scope: "write"
      }, as: :json
    end

    assert_response :created
    assert_equal "read write", response.parsed_body["scope"]
    assert_equal %w[ read write ], Oauth::Client.find_by(client_id: response.parsed_body["client_id"]).scopes
  end

  test "DCR refuses more redirect URIs, or longer ones, than a client needs" do
    many = (1..11).map { |port| "http://127.0.0.1:#{8000 + port}/callback" }
    long = "http://127.0.0.1:8888/#{"a" * 2000}"

    [ many, [ long ] ].each do |redirect_uris|
      assert_no_difference "Oauth::Client.count" do
        untenanted { post oauth_clients_path, params: { client_name: "Big", redirect_uris: redirect_uris }, as: :json }
      end

      assert_response :bad_request
      assert_equal "invalid_client_metadata", response.parsed_body["error"]
    end

    untenanted { post oauth_clients_path, params: { client_name: "Ten", redirect_uris: many.first(10) }, as: :json }
    assert_response :created
  end

  test "DCR creates client with https redirect" do
    assert_difference "Oauth::Client.count", 1 do
      untenanted do
        post oauth_clients_path, params: {
          client_name: "Hosted Connector",
          redirect_uris: [ "https://connector.example.com/callback" ]
        }, as: :json
      end
    end

    assert_response :created
    body = response.parsed_body

    assert_not_nil body["client_id"]
    assert_equal [ "https://connector.example.com/callback" ], body["redirect_uris"]
  end

  test "DCR rejects plain http non-loopback redirect" do
    assert_no_difference "Oauth::Client.count" do
      untenanted do
        post oauth_clients_path, params: {
          client_name: "Evil Client",
          redirect_uris: [ "http://evil.com/steal" ]
        }, as: :json
      end
    end

    assert_response :bad_request
    assert_equal "invalid_redirect_uri", response.parsed_body["error"]
  end

  test "DCR rejects a client_name longer than 255 characters" do
    assert_no_difference "Oauth::Client.count" do
      untenanted do
        post oauth_clients_path, params: {
          client_name: "a" * 256,
          redirect_uris: [ "http://127.0.0.1:8888/callback" ]
        }, as: :json
      end
    end

    assert_response :bad_request
    assert_equal "invalid_client_metadata", response.parsed_body["error"]
  end

  test "DCR accepts a 255-character client_name" do
    untenanted do
      post oauth_clients_path, params: {
        client_name: "a" * 255,
        redirect_uris: [ "http://127.0.0.1:8888/callback" ]
      }, as: :json
    end

    assert_response :created
    assert_equal "a" * 255, response.parsed_body["client_name"]
  end

  test "DCR falls back to the default name for a non-string client_name" do
    untenanted do
      post oauth_clients_path, params: {
        client_name: { "en" => "Sneaky" },
        redirect_uris: [ "http://127.0.0.1:8888/callback" ]
      }, as: :json
    end

    assert_response :created
    assert_equal "MCP Client", response.parsed_body["client_name"]
  end

  test "DCR rejects https loopback redirect" do
    assert_no_difference "Oauth::Client.count" do
      untenanted do
        post oauth_clients_path, params: {
          client_name: "HTTPS Loopback",
          redirect_uris: [ "https://127.0.0.1:8888/callback" ]
        }, as: :json
      end
    end

    assert_response :bad_request
    assert_equal "invalid_redirect_uri", response.parsed_body["error"]
  end

  test "DCR rejects https loopback redirect regardless of host case" do
    assert_no_difference "Oauth::Client.count" do
      untenanted do
        post oauth_clients_path, params: {
          client_name: "Cased Loopback",
          redirect_uris: [ "https://LOCALHOST:8888/callback" ]
        }, as: :json
      end
    end

    assert_response :bad_request
    assert_equal "invalid_redirect_uri", response.parsed_body["error"]
  end

  test "DCR rejects percent-encoded https loopback redirect" do
    assert_no_difference "Oauth::Client.count" do
      untenanted do
        post oauth_clients_path, params: {
          client_name: "Encoded Loopback",
          redirect_uris: [ "https://%6cocalhost:8888/callback" ]
        }, as: :json
      end
    end

    assert_response :bad_request
    assert_equal "invalid_redirect_uri", response.parsed_body["error"]
  end

  test "DCR rejects a redirect whose authority isn't plain" do
    %w[ https://%65vil.example/callback https://user:secret@connector.example.com/callback
        https://connector.example.com:65536/callback http://user@127.0.0.1:8888/callback http://%5B%3A%3A1%5D:8888/callback ].each do |uri|
      assert_no_difference "Oauth::Client.count" do
        untenanted { post oauth_clients_path, params: { client_name: "Unplain", redirect_uris: [ uri ] }, as: :json }
      end

      assert_response :bad_request, uri
      assert_equal "invalid_redirect_uri", response.parsed_body["error"], uri
    end
  end

  test "DCR rejects a redirect whose host decodes to invalid UTF-8" do
    assert_no_difference "Oauth::Client.count" do
      untenanted do
        post oauth_clients_path, params: {
          client_name: "Broken Host",
          redirect_uris: [ "https://%FF/callback" ]
        }, as: :json
      end
    end

    assert_response :bad_request
    assert_equal "invalid_redirect_uri", response.parsed_body["error"]
  end

  test "DCR rejects https redirect with fragment" do
    assert_no_difference "Oauth::Client.count" do
      untenanted do
        post oauth_clients_path, params: {
          client_name: "Fragment Client",
          redirect_uris: [ "https://connector.example.com/callback#section" ]
        }, as: :json
      end
    end

    assert_response :bad_request
    assert_equal "invalid_redirect_uri", response.parsed_body["error"]
  end

  test "https redirect requires exact match in authorization" do
    sign_in_as :david

    untenanted do
      post oauth_clients_path, params: {
        client_name: "Hosted Connector",
        redirect_uris: [ "https://connector.example.com/callback" ]
      }, as: :json
    end
    client_id = response.parsed_body["client_id"]

    untenanted do
      get new_oauth_authorization_path, params: {
        client_id: client_id,
        redirect_uri: "https://connector.example.com/callback",
        response_type: "code",
        code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
        code_challenge_method: "S256",
        scope: "read",
        state: "xyz123"
      }
    end
    assert_response :success
    assert_match "connector.example.com", response.body

    untenanted do
      get new_oauth_authorization_path, params: {
        client_id: client_id,
        redirect_uri: "https://connector.example.com:8443/callback",
        response_type: "code",
        code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
        code_challenge_method: "S256",
        scope: "read",
        state: "xyz123"
      }
    end
    assert_response :bad_request
  end

  test "DCR registers a confidential client with client_secret_post" do
    untenanted do
      post oauth_clients_path, params: {
        client_name: "Hosted Connector",
        redirect_uris: [ "https://connector.example.com/callback" ],
        token_endpoint_auth_method: "client_secret_post"
      }, as: :json
    end

    assert_response :created
    body = response.parsed_body

    assert_equal "client_secret_post", body["token_endpoint_auth_method"]
    assert_not_nil body["client_secret"]
    assert_equal 0, body["client_secret_expires_at"]
    assert_equal "no-store", response.headers["Cache-Control"]
    assert Oauth::Client.find_by(client_id: body["client_id"]).confidential?
  end

  # The registration response is the only place a client secret is shown. It's
  # stored as a digest, which verifies it.
  test "a registered client secret is stored digested and authenticates its client" do
    untenanted do
      post oauth_clients_path, params: {
        client_name: "Hosted Connector",
        redirect_uris: [ "https://connector.example.com/callback" ],
        token_endpoint_auth_method: "client_secret_basic"
      }, as: :json
    end

    secret = response.parsed_body["client_secret"]
    client = Oauth::Client.find_by(client_id: response.parsed_body["client_id"])

    assert_equal TokenDigester.digest(secret), client.client_secret_digest
    assert_not_includes client.attributes.except("raw_client_secret").values.compact.map(&:to_s), secret
    assert_nil client.raw_client_secret
    assert client.authenticate_secret(secret)
    assert_not client.authenticate_secret(secret.succ)

    grant = identities(:david).access_tokens.create!(oauth_client: client)
    untenanted do
      post oauth_token_path, params: { grant_type: "refresh_token", refresh_token: grant.raw_refresh_token },
        headers: { "Authorization" => "Basic #{Base64.strict_encode64("#{client.client_id}:#{secret}")}" }
    end
    assert_response :success
  end

  test "DCR omits client_secret for public clients" do
    untenanted do
      post oauth_clients_path, params: {
        client_name: "Public Client",
        redirect_uris: [ "http://127.0.0.1:8888/callback" ]
      }, as: :json
    end

    assert_response :created
    body = response.parsed_body

    assert_equal "none", body["token_endpoint_auth_method"]
    assert_not body.key?("client_secret")
    assert_not body.key?("client_secret_expires_at")
  end

  test "DCR rejects unsupported token_endpoint_auth_method" do
    assert_no_difference "Oauth::Client.count" do
      untenanted do
        post oauth_clients_path, params: {
          client_name: "JWT Client",
          redirect_uris: [ "https://connector.example.com/callback" ],
          token_endpoint_auth_method: "private_key_jwt"
        }, as: :json
      end
    end

    assert_response :bad_request
    assert_equal "invalid_client_metadata", 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 = URI.decode_www_form(redirect_uri.query).to_h
    code = params["code"]
    assert_not_nil code
    assert_equal "test_state", params["state"]

    # Step 3: Exchange code for token
    assert_difference "Identity::AccessToken.count", 1 do
      untenanted do
        post oauth_token_path, params: {
          grant_type: "authorization_code",
          client_id: oauth_clients(:mcp_client).client_id,
          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

  private
    def get_consent_screen(client: oauth_clients(:mcp_client), **params)
      untenanted do
        get new_oauth_authorization_path, params: consent_params(client, **params).compact
      end
    end

    def post_consent(client: oauth_clients(:mcp_client), **params)
      untenanted do
        post oauth_authorization_path, params: consent_params(client, **params).compact
      end
    end

    def consent_params(client, **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 form_action_sources
      response.headers["Content-Security-Policy"].split(";").map(&:strip) \
        .find { |directive| directive.start_with?("form-action ") }.split.drop(1)
    end

    def authorization_code_for(client, code_verifier:, identity: identities(:david), scope: "read")
      Oauth::AuthorizationCode.generate \
        client_id: client.client_id,
        identity_id: identity.id,
        code_challenge: Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false),
        redirect_uri: "http://127.0.0.1:8888/callback",
        scope: scope
    end

    def with_forgery_protection
      ActionController::Base.allow_forgery_protection = true
      yield
    ensure
      ActionController::Base.allow_forgery_protection = false
    end
end