test/integration/oauth_flow_test.rb
Rama main · réplica de lectura del repositorio original.
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