Class: Hitch::Client
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- Hitch::Client
- Defined in:
- app/models/hitch/client.rb,
app/models/hitch/client/credentials.rb
Overview
OAuth Dynamic Client Registration (RFC 7591) record. Captures the human-readable client_name an MCP client sends during DCR so the authorize flow can attribute records back to the originating application (Claude Code, ChatGPT, Cursor, etc.).
The client_name is attacker-controllable (anyone can POST to /oauth/register with any client_name); consent UIs should NOT trust it for display. Storage keeps it for audit fidelity.
Defined Under Namespace
Classes: Credentials, InvalidRegistrationMetadata
Constant Summary collapse
- TOKEN_ENDPOINT_AUTH_METHODS =
%w[none client_secret_basic].freeze
- CLIENT_SECRET_BYTES =
48- MAX_CLIENT_ID_BYTES =
255- MAX_CLIENT_NAME_BYTES =
255- MAX_REDIRECT_URIS =
32- MAX_REDIRECT_URI_BYTES =
255- APPLICATION_TYPES =
OpenID Connect Dynamic Client Registration 1.0 ยง2 defines exactly these two. (Not RFC 7591 โ that spec has no application_type; the field is IANA-registered, which is how it rides along in an otherwise RFC 7591 registration request.) A client sending anything else is recorded as having declared nothing, rather than having its registration rejected โ see #normalize_application_type.
%w[native web].freeze
Class Method Summary collapse
- .digest_secret(secret) ⇒ Object
- .generate_client_secret ⇒ Object
-
.normalize_application_type(value) ⇒ Object
Unrecognized values become nil rather than a registration error.
- .normalize_redirect_uris!(values, allow_empty: true) ⇒ Object
-
.normalize_registration_metadata!(client_name:, redirect_uris:, client_id: nil) ⇒ Object
Shared size and shape boundary for HTTP registration, operator tasks, and direct framework callers.
- .register!(client_id:, client_name:, redirect_uris:, application_type: nil) ⇒ Object
- .register_confidential!(client_id:, client_name:, redirect_uris:, application_type: nil, operator_registered: false) ⇒ Object
Instance Method Summary collapse
- #authenticates_secret?(candidate) ⇒ Boolean
- #confidential_client? ⇒ Boolean
- #operator_registered_confidential_client? ⇒ Boolean
- #public_client? ⇒ Boolean
- #redirect_uris ⇒ Object
-
#redirect_uris=(values) ⇒ Object
Works on new and persisted records alike: unpersisted clients build association records that save with the parent; persisted clients replace their rows atomically.
- #replace_redirect_uris!(values) ⇒ Object
- #rotate_secret! ⇒ Object
Class Method Details
.digest_secret(secret) ⇒ Object
155 156 157 |
# File 'app/models/hitch/client.rb', line 155 def self.digest_secret(secret) Digest::SHA256.hexdigest(secret.to_s) end |
.generate_client_secret ⇒ Object
159 160 161 |
# File 'app/models/hitch/client.rb', line 159 def self.generate_client_secret SecureRandom.urlsafe_base64(CLIENT_SECRET_BYTES) end |
.normalize_application_type(value) ⇒ Object
Unrecognized values become nil rather than a registration error.
application_type is recorded, never enforced (see the migration),
so a junk value costs nothing to drop โ whereas rejecting the
registration would break a client over a field the server does not
yet act on. Absent and unrecognized are both "did not declare",
which is the honest reading of each.
104 105 106 107 |
# File 'app/models/hitch/client.rb', line 104 def self.normalize_application_type(value) value = value.to_s APPLICATION_TYPES.include?(value) ? value : nil end |
.normalize_redirect_uris!(values, allow_empty: true) ⇒ Object
123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 |
# File 'app/models/hitch/client.rb', line 123 def self.normalize_redirect_uris!(values, allow_empty: true) unless values.is_a?(Array) && (allow_empty || values.any?) && values.length <= MAX_REDIRECT_URIS raise InvalidRegistrationMetadata, "redirect_uris must be an array of #{allow_empty ? '0' : '1'}..#{MAX_REDIRECT_URIS} strings" end normalized = values.map do |value| bounded_string!(value, :redirect_uri, max_bytes: MAX_REDIRECT_URI_BYTES) end if normalized.uniq.length != normalized.length raise InvalidRegistrationMetadata, "redirect_uris must not contain duplicates" end normalized end |
.normalize_registration_metadata!(client_name:, redirect_uris:, client_id: nil) ⇒ Object
Shared size and shape boundary for HTTP registration, operator tasks, and direct framework callers. URI scheme policy remains protocol-level; this method guarantees every persistence path is finite and lossless. client_id is optional: HTTP registration mints one only after the rest of the metadata is admitted, so it has nothing to validate here.
114 115 116 117 118 119 120 121 |
# File 'app/models/hitch/client.rb', line 114 def self.(client_name:, redirect_uris:, client_id: nil) { client_id: client_id.nil? ? nil : bounded_string!(client_id, :client_id, max_bytes: MAX_CLIENT_ID_BYTES), client_name: client_name.nil? ? "MCP Client" : bounded_string!(client_name, :client_name, max_bytes: MAX_CLIENT_NAME_BYTES), redirect_uris: normalize_redirect_uris!(redirect_uris, allow_empty: false) } end |
.register!(client_id:, client_name:, redirect_uris:, application_type: nil) ⇒ Object
45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 |
# File 'app/models/hitch/client.rb', line 45 def self.register!(client_id:, client_name:, redirect_uris:, application_type: nil) = ( client_id: client_id, client_name: client_name, redirect_uris: redirect_uris ) validate_application_type_shape!(application_type) transaction do client = create!( client_id: .fetch(:client_id), client_name: .fetch(:client_name), application_type: normalize_application_type(application_type), token_endpoint_auth_method: "none", operator_registered: false ) client.replace_redirect_uris!(.fetch(:redirect_uris)) client end end |
.register_confidential!(client_id:, client_name:, redirect_uris:, application_type: nil, operator_registered: false) ⇒ Object
66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 |
# File 'app/models/hitch/client.rb', line 66 def self.register_confidential!( client_id:, client_name:, redirect_uris:, application_type: nil, operator_registered: false ) = ( client_id: client_id, client_name: client_name, redirect_uris: redirect_uris ) validate_application_type_shape!(application_type) raw_secret = generate_client_secret client = transaction do record = create!( client_id: .fetch(:client_id), client_name: .fetch(:client_name), application_type: normalize_application_type(application_type), token_endpoint_auth_method: "client_secret_basic", client_secret_digest: digest_secret(raw_secret), client_secret_issued_at: Time.current, operator_registered: operator_registered ) record.replace_redirect_uris!(.fetch(:redirect_uris)) record end Credentials.new(client: client, client_secret: raw_secret) end |
Instance Method Details
#authenticates_secret?(candidate) ⇒ Boolean
175 176 177 178 179 180 |
# File 'app/models/hitch/client.rb', line 175 def authenticates_secret?(candidate) return false unless confidential_client? && client_secret_digest.present? && candidate.present? candidate_digest = self.class.digest_secret(candidate) ActiveSupport::SecurityUtils.secure_compare(client_secret_digest, candidate_digest) end |
#confidential_client? ⇒ Boolean
167 168 169 |
# File 'app/models/hitch/client.rb', line 167 def confidential_client? token_endpoint_auth_method == "client_secret_basic" end |
#operator_registered_confidential_client? ⇒ Boolean
171 172 173 |
# File 'app/models/hitch/client.rb', line 171 def operator_registered_confidential_client? confidential_client? && operator_registered? end |
#public_client? ⇒ Boolean
163 164 165 |
# File 'app/models/hitch/client.rb', line 163 def public_client? token_endpoint_auth_method == "none" end |
#redirect_uris ⇒ Object
198 199 200 |
# File 'app/models/hitch/client.rb', line 198 def redirect_uris redirect_uri_records.order(:uri).pluck(:uri) end |
#redirect_uris=(values) ⇒ Object
Works on new and persisted records alike: unpersisted clients build association records that save with the parent; persisted clients replace their rows atomically.
205 206 207 208 209 210 211 212 |
# File 'app/models/hitch/client.rb', line 205 def redirect_uris=(values) desired = self.class.normalize_redirect_uris!(values) if persisted? replace_redirect_uris!(desired) else self.redirect_uri_records = desired.map { |uri| Hitch::ClientRedirectUri.new(uri: uri) } end end |
#replace_redirect_uris!(values) ⇒ Object
214 215 216 217 218 219 220 221 222 223 224 225 226 |
# File 'app/models/hitch/client.rb', line 214 def replace_redirect_uris!(values) desired = self.class.normalize_redirect_uris!(values) transaction do if desired.empty? redirect_uri_records.delete_all else redirect_uri_records.where.not(uri: desired).delete_all end existing = redirect_uri_records.where(uri: desired).pluck(:uri) (desired - existing).each { |uri| redirect_uri_records.create!(uri: uri) } end redirect_uris end |
#rotate_secret! ⇒ Object
182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 |
# File 'app/models/hitch/client.rb', line 182 def rotate_secret! raise ArgumentError, "public clients do not have a client secret" unless confidential_client? raw_secret = nil with_lock do raw_secret = self.class.generate_client_secret now = Time.current update!( client_secret_digest: self.class.digest_secret(raw_secret), client_secret_issued_at: now, client_secret_rotated_at: now ) end Credentials.new(client: self, client_secret: raw_secret) end |