Discovery advertised the device_code grant in grant_types_supported, but dynamic client registration only allowed authorization_code/refresh_token, so a client listing the device grant in its RFC 7591 metadata was rejected with invalid_client_metadata. Introduce a single source of truth — OidcController::SUPPORTED_GRANT_TYPES — used by discovery (grant_types_supported) and by registration validation, so advertisement and registration can never drift. The token dispatcher already handles exactly these grants for all clients (they are user-context grants gated by consent + user_allowed?, so there is no per-client grant restriction), making advertisement, registration, and enforcement consistent. Tests: a client registering with the device_code grant now succeeds, plus an assertion that registration's accepted set equals discovery's advertised set. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F7cwhwDJp3MJJDoNPVE6zq
157 lines
5.7 KiB
Ruby
157 lines
5.7 KiB
Ruby
require "test_helper"
|
|
|
|
class OidcRegistrationControllerTest < ActionDispatch::IntegrationTest
|
|
JSON_HEADERS = {"Content-Type" => "application/json"}.freeze
|
|
|
|
def teardown
|
|
Setting.where(key: Application::DCR_SETTING_KEY).delete_all
|
|
Application.where("slug LIKE ?", "%-%").where(metadata: nil).delete_all
|
|
Application.where("metadata LIKE ?", "%dynamically_registered%").destroy_all
|
|
end
|
|
|
|
def enable_dcr
|
|
Setting.set(Application::DCR_SETTING_KEY, true)
|
|
end
|
|
|
|
def register(body)
|
|
post "/oauth/register", params: body.to_json, headers: JSON_HEADERS
|
|
end
|
|
|
|
test "registration is disabled by default" do
|
|
register(redirect_uris: ["https://client.example.com/cb"], token_endpoint_auth_method: "none")
|
|
assert_response :forbidden
|
|
assert_equal "access_denied", JSON.parse(@response.body)["error"]
|
|
end
|
|
|
|
test "registers a public client and returns no secret" do
|
|
enable_dcr
|
|
register(
|
|
redirect_uris: ["https://client.example.com/cb"],
|
|
token_endpoint_auth_method: "none",
|
|
grant_types: ["authorization_code", "refresh_token"],
|
|
client_name: "My MCP Connector"
|
|
)
|
|
|
|
assert_response :created
|
|
body = JSON.parse(@response.body)
|
|
assert body["client_id"].present?
|
|
assert_not body.key?("client_secret")
|
|
assert_equal ["https://client.example.com/cb"], body["redirect_uris"]
|
|
assert_equal "none", body["token_endpoint_auth_method"]
|
|
|
|
app = Application.find_by(client_id: body["client_id"])
|
|
assert app.public_client?
|
|
assert app.require_pkce?
|
|
assert_empty app.allowed_groups, "a freshly registered client must be default-deny"
|
|
end
|
|
|
|
test "registers a confidential client and returns a secret once" do
|
|
enable_dcr
|
|
register(
|
|
redirect_uris: ["https://client.example.com/cb"],
|
|
token_endpoint_auth_method: "client_secret_basic"
|
|
)
|
|
|
|
assert_response :created
|
|
body = JSON.parse(@response.body)
|
|
assert body["client_secret"].present?
|
|
assert_equal 0, body["client_secret_expires_at"]
|
|
|
|
app = Application.find_by(client_id: body["client_id"])
|
|
assert app.confidential_client?
|
|
assert app.authenticate_client_secret(body["client_secret"])
|
|
end
|
|
|
|
test "requires at least one redirect_uri" do
|
|
enable_dcr
|
|
register(token_endpoint_auth_method: "none")
|
|
assert_response :bad_request
|
|
assert_equal "invalid_redirect_uri", JSON.parse(@response.body)["error"]
|
|
end
|
|
|
|
test "rejects non-loopback http redirect_uris" do
|
|
enable_dcr
|
|
register(redirect_uris: ["http://evil.example.com/cb"], token_endpoint_auth_method: "none")
|
|
assert_response :bad_request
|
|
assert_equal "invalid_redirect_uri", JSON.parse(@response.body)["error"]
|
|
end
|
|
|
|
test "allows http redirect_uris for loopback" do
|
|
enable_dcr
|
|
register(redirect_uris: ["http://localhost:8123/cb"], token_endpoint_auth_method: "none")
|
|
assert_response :created
|
|
end
|
|
|
|
test "rejects unsupported grant types" do
|
|
enable_dcr
|
|
register(redirect_uris: ["https://client.example.com/cb"], grant_types: ["client_credentials"])
|
|
assert_response :bad_request
|
|
assert_equal "invalid_client_metadata", JSON.parse(@response.body)["error"]
|
|
end
|
|
|
|
test "registers a client requesting the device_code grant advertised in discovery" do
|
|
enable_dcr
|
|
register(
|
|
redirect_uris: ["https://client.example.com/cb"],
|
|
token_endpoint_auth_method: "none",
|
|
grant_types: ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"]
|
|
)
|
|
assert_response :created
|
|
assert_includes JSON.parse(@response.body)["grant_types"], "urn:ietf:params:oauth:grant-type:device_code"
|
|
end
|
|
|
|
test "registration accepts exactly the grant types discovery advertises" do
|
|
get "/.well-known/openid-configuration"
|
|
advertised = JSON.parse(@response.body)["grant_types_supported"]
|
|
# Single source of truth: what we advertise is what registration accepts.
|
|
assert_equal OidcController::SUPPORTED_GRANT_TYPES, advertised
|
|
assert_includes advertised, "urn:ietf:params:oauth:grant-type:device_code"
|
|
end
|
|
|
|
test "rejects a non-JSON body" do
|
|
enable_dcr
|
|
post "/oauth/register", params: "not json", headers: JSON_HEADERS
|
|
assert_response :bad_request
|
|
assert_equal "invalid_client_metadata", JSON.parse(@response.body)["error"]
|
|
end
|
|
|
|
# --- Discovery advertisement ----------------------------------------------
|
|
|
|
test "discovery advertises registration_endpoint only when enabled" do
|
|
get "/.well-known/openid-configuration"
|
|
assert_not JSON.parse(@response.body).key?("registration_endpoint")
|
|
|
|
enable_dcr
|
|
get "/.well-known/openid-configuration"
|
|
assert JSON.parse(@response.body)["registration_endpoint"].end_with?("/oauth/register")
|
|
end
|
|
|
|
test "RFC 8414 metadata alias mirrors OIDC discovery" do
|
|
get "/.well-known/oauth-authorization-server"
|
|
assert_response :success
|
|
config = JSON.parse(@response.body)
|
|
assert config["token_endpoint"].end_with?("/oauth/token")
|
|
assert config["authorization_endpoint"].end_with?("/oauth/authorize")
|
|
end
|
|
|
|
# --- Admin runtime toggle --------------------------------------------------
|
|
|
|
test "admin can toggle the registration window at runtime" do
|
|
sign_in_as(users(:alice)) # alice is in the admin group
|
|
|
|
patch "/admin/dynamic_client_registration", params: {enabled: "true"}
|
|
assert_redirected_to admin_applications_path
|
|
assert Application.dynamic_registration_enabled?
|
|
|
|
patch "/admin/dynamic_client_registration", params: {enabled: "false"}
|
|
assert_not Application.dynamic_registration_enabled?
|
|
end
|
|
|
|
test "non-admins cannot toggle registration" do
|
|
sign_in_as(users(:one)) # not an admin
|
|
patch "/admin/dynamic_client_registration", params: {enabled: "true"}
|
|
assert_redirected_to root_path
|
|
assert_not Application.dynamic_registration_enabled?
|
|
end
|
|
end
|