Class: Hitch::Client

Inherits:
ApplicationRecord show all
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

Instance Method Summary collapse

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_secretObject



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.normalize_registration_metadata!(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)
   = normalize_registration_metadata!(
    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
)
   = normalize_registration_metadata!(
    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

Returns:

  • (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

Returns:

  • (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

Returns:

  • (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

Returns:

  • (Boolean)


163
164
165
# File 'app/models/hitch/client.rb', line 163

def public_client?
  token_endpoint_auth_method == "none"
end

#redirect_urisObject



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

Raises:

  • (ArgumentError)


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