Files
clinch/app/controllers/oidc_registration_controller.rb
T
Dan MilneandClaude Opus 4.8 7eddea8356 DCR: accept every grant type discovery advertises
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
2026-07-19 13:59:35 +10:00

148 lines
5.5 KiB
Ruby

require "uri"
# OAuth 2.0 Dynamic Client Registration (RFC 7591).
#
# Lets a client (e.g. an MCP connector such as Claude) register itself instead of
# being hand-created in the admin UI. Gated by a runtime toggle
# (Application.dynamic_registration_enabled?) that defaults off. Registered
# clients are default-deny — they have no allowed_groups until an admin attaches
# one — so an anonymous registration cannot reach any user's data on its own.
class OidcRegistrationController < ApplicationController
allow_unauthenticated_access only: [:create]
skip_before_action :verify_authenticity_token, only: [:create]
rate_limit to: 10, within: 1.minute, only: [:create], with: -> {
render json: {error: "too_many_requests", error_description: "Rate limit exceeded. Try again later."}, status: :too_many_requests
}
AUTH_METHODS = %w[none client_secret_basic client_secret_post].freeze
SUPPORTED_RESPONSE_TYPES = %w[code].freeze
# Accept exactly the grant types the authorization server advertises in
# discovery (single source of truth), so a client cannot be rejected for
# requesting a grant the server actually supports (e.g. the device_code grant).
def supported_grant_types
OidcController::SUPPORTED_GRANT_TYPES
end
# POST /oauth/register
def create
unless Application.dynamic_registration_enabled?
render json: {error: "access_denied", error_description: "Dynamic client registration is disabled"}, status: :forbidden
return
end
metadata = parse_body
if metadata == :invalid
return register_error("invalid_client_metadata", "Request body must be a valid JSON object")
end
auth_method = metadata["token_endpoint_auth_method"].presence || "client_secret_basic"
unless AUTH_METHODS.include?(auth_method)
return register_error("invalid_client_metadata", "Unsupported token_endpoint_auth_method")
end
grant_types = Array(metadata["grant_types"].presence || ["authorization_code"])
if (grant_types - supported_grant_types).any?
return register_error("invalid_client_metadata", "Unsupported grant_types; only #{supported_grant_types.join(", ")} are allowed")
end
response_types = Array(metadata["response_types"].presence || ["code"])
if (response_types - SUPPORTED_RESPONSE_TYPES).any?
return register_error("invalid_client_metadata", "Unsupported response_types; only 'code' is allowed")
end
redirect_uris = Array(metadata["redirect_uris"]).map(&:to_s).reject(&:blank?)
if redirect_uris.empty?
return register_error("invalid_redirect_uri", "At least one redirect_uri is required")
end
invalid = redirect_uris.reject { |uri| valid_redirect_uri?(uri) }
if invalid.any?
return register_error("invalid_redirect_uri", "Invalid redirect_uri: #{invalid.first}")
end
public_client = (auth_method == "none")
client_name = metadata["client_name"].to_s.strip.presence || "Dynamically Registered Client"
application = Application.new(
name: client_name,
slug: unique_slug(client_name),
app_type: "oidc",
active: true,
# MCP / OAuth 2.1 expect PKCE; public clients require it automatically.
require_pkce: true,
is_public_client: public_client,
redirect_uris: redirect_uris.to_json,
metadata: registration_metadata(metadata, auth_method).to_json
)
unless application.save
return register_error("invalid_client_metadata", application.errors.full_messages.join("; "))
end
body = {
client_id: application.client_id,
client_id_issued_at: application.created_at.to_i,
redirect_uris: redirect_uris,
token_endpoint_auth_method: auth_method,
grant_types: grant_types,
response_types: response_types,
client_name: client_name
}
body[:scope] = metadata["scope"] if metadata["scope"].present?
# Return the plaintext secret exactly once, for confidential clients.
if application.confidential_client?
body[:client_secret] = application.client_secret
body[:client_secret_expires_at] = 0 # never expires
end
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
render json: body, status: :created
end
private
def parse_body
parsed = JSON.parse(request.raw_post)
parsed.is_a?(Hash) ? parsed : :invalid
rescue JSON::ParserError
:invalid
end
def register_error(error, description)
render json: {error: error, error_description: description}, status: :bad_request
end
# RFC 7591 allows https everywhere and http only for loopback (native apps).
def valid_redirect_uri?(uri)
parsed = URI.parse(uri)
return false unless parsed.is_a?(URI::HTTP) # covers HTTP and HTTPS
return true if parsed.scheme == "https"
%w[localhost 127.0.0.1 ::1].include?(parsed.host)
rescue URI::InvalidURIError
false
end
def unique_slug(name)
base = name.parameterize.presence || "client"
"#{base.first(40)}-#{SecureRandom.hex(6)}"
end
# Preserve the descriptive metadata the client sent for later reference in the
# admin UI, without letting it drive access.
def registration_metadata(metadata, auth_method)
{
"dynamically_registered" => true,
"token_endpoint_auth_method" => auth_method,
"client_uri" => metadata["client_uri"],
"logo_uri" => metadata["logo_uri"],
"contacts" => metadata["contacts"],
"policy_uri" => metadata["policy_uri"],
"tos_uri" => metadata["tos_uri"],
"scope" => metadata["scope"]
}.compact
end
end