Class: Hitch::ClientIdMetadata

Inherits:
Object
  • Object
show all
Defined in:
app/models/hitch/client_id_metadata.rb,
app/models/hitch/client_id_metadata/cache.rb,
app/models/hitch/client_id_metadata/fetcher.rb,
app/models/hitch/client_id_metadata/throttle.rb

Overview

Client ID Metadata Documents (CIMD).

MCP 2026-07-28 deprecates Dynamic Client Registration in favour of CIMD: instead of pre-registering and receiving an opaque client_id, a client uses an https URL as its client_id, and the authorization server fetches the client metadata from that URL.

That inverts the trust model. DCR data arrives on a request the server is already handling; CIMD makes the AUTHORIZATION SERVER issue an outbound request to a URL the caller chose. /oauth/authorize requires a signed-in principal, so the caller is authenticated rather than anonymous — a low bar on any host with open sign-up, and note the GET consent path carries no CSRF token, so a fetch can be driven from a logged-in victim's browser. Every guard here exists because of that inversion:

- https only, no redirects followed, no userinfo, no fragment
- DNS resolved once, every address checked against a blocklist of
non-public ranges, then the connection PINNED to the checked
address via Net::HTTP#ipaddr= so a second lookup can't return a
different answer (DNS rebinding)
- hard caps on time and response size
- the document's own `client_id` must equal the URL it came from,
so a document cannot claim to be a different client
- successes and failures are both cached, so a hostile or dead URL
cannot be used to make the authorize endpoint issue an outbound
request per inbound request. Note this one depends on the host
having a real Rails.cache: under a NullStore (Rails' default in
test, and in development without tmp/caching-dev.txt) nothing is
retained between requests and the amplification guard is absent.

Disabled unless the host opts in (config.client_id_metadata_enabled). The feature adds an outbound-fetch surface to an endpoint that had none, and DCR still works, so it is not something to switch on for an adopter who has not considered it.

Defined Under Namespace

Modules: Cache Classes: Diagnosis, Document, Fetcher, Throttle

Constant Summary collapse

ALLOWED_PORT =

CIMD documents live on ordinary https endpoints. Allowing an arbitrary port would let a caller drive TLS connections to any host:port from the authorization server's egress address — the standard way around a third party's source-IP allowlist.

443
HOST_FAILURE =

Failure sentinels (see Fetcher and Throttle for the semantics).

Fetcher::HOST_FAILURE
CAPACITY_EXCEEDED =

Refused because a cap was already spent — no fetch was attempted, so nothing is known and nothing may be cached (see Throttle).

Throttle::CAPACITY_EXCEEDED
RATE_LIMITED =

Refused because this principal spent its minute budget. Same rule.

:rate_limited

Class Method Summary collapse

Class Method Details

.diagnose(client_id) ⇒ Object

Operator-facing check that this host can actually reach and parse a client metadata document, for confirming egress before enabling CIMD. Takes a URL the operator already trusts.

Reports only. Whether one document is reachable right now is a different question from whether this server supports CIMD, and only the second belongs in the discovery document: a capability that moved with network conditions would be stale for up to the discovery cache lifetime, and would tell clients nothing they could act on.

Skips the caches and the per-principal limit (there is no principal) but not the SSRF constraints or the concurrency cap — exercising the real fetch path is the entire point.



201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
# File 'app/models/hitch/client_id_metadata.rb', line 201

def diagnose(client_id)
  # Deliberately ignores client_id_metadata_enabled. The whole point
  # is to answer "can this host reach a document?" BEFORE deciding
  # to turn CIMD on, so gating the probe on the setting it informs
  # makes it useless exactly when it is needed. That flag governs
  # discovery and real authorization traffic; it does not govern an
  # operator running a command.
  unless document_url?(client_id)
    return Diagnosis.new(outcome: :not_a_reference,
                         detail: "not an https URL with a path component, so it would be treated as an opaque client_id")
  end

  target = fetch_target(client_id)
  if target.nil?
    return Diagnosis.new(outcome: :rejected_shape,
                         detail: "must be https on port #{ALLOWED_PORT}, with no userinfo and no fragment")
  end

  case (outcome = with_fetch_capacity { Fetcher.call(client_id, target) })
  when Array
    Diagnosis.new(outcome: :ok, detail: "resolved #{outcome.first.redirect_uris.length} redirect_uri(s)")
  when CAPACITY_EXCEEDED
    Diagnosis.new(outcome: :no_capacity, detail: "every fetch slot is currently busy")
  when HOST_FAILURE
    Diagnosis.new(outcome: :unreachable,
                  detail: "DNS, connect, TLS or timeout failed — check direct egress on port #{ALLOWED_PORT}; " \
                          "an ambient http_proxy is deliberately ignored")
  else
    Diagnosis.new(outcome: :invalid_document,
                  detail: "the host answered but the document was unusable — the log line for this URL says why")
  end
end

.document_url?(client_id) ⇒ Boolean

The shape half of reference?, without consulting the enablement flag. Split out so the operator diagnostic can run BEFORE CIMD is switched on — which is the only moment its answer is useful.

"The client_id URL MUST use the 'https' scheme and contain a path component" — MCP 2026-07-28, Client Registration. A bare origin is not a metadata document URL, so it falls through to the opaque/DCR lookup rather than triggering an outbound fetch.

Returns:

  • (Boolean)


86
87
88
89
90
91
92
93
# File 'app/models/hitch/client_id_metadata.rb', line 86

def document_url?(client_id)
  return false if client_id.blank?

  uri = URI.parse(client_id.to_s)
  uri.is_a?(URI::HTTPS) && uri.host.present? && uri.path.present? && uri.path != "/"
rescue URI::InvalidURIError
  false
end

.fetches_in_flightObject

Number of fetches in flight right now. Test seam.



183
184
185
# File 'app/models/hitch/client_id_metadata.rb', line 183

def fetches_in_flight
  @throttle.in_flight
end

.reference?(client_id) ⇒ Boolean

A client_id is a CIMD reference when it is an https URL. Opaque DCR client_ids (UUIDs) never match, so the two schemes coexist without ambiguity.

Returns:

  • (Boolean)


72
73
74
75
76
# File 'app/models/hitch/client_id_metadata.rb', line 72

def reference?(client_id)
  return false unless Hitch.configuration.

  document_url?(client_id)
end

.resolve(client_id, actor: nil) ⇒ Object

Returns a Document, or nil for anything that isn't a usable client metadata document. Never raises into the authorize flow: a fetch failure is an untrusted client's problem, not a 500. actor identifies the signed-in principal driving this resolution, for per-actor rate limiting. Optional: omitted, only the concurrency cap applies.



101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'app/models/hitch/client_id_metadata.rb', line 101

def resolve(client_id, actor: nil)
  return nil unless reference?(client_id)

  key = Cache.key(client_id)
  cached = Cache.read(key)

  unless cached.nil?
    return nil if cached == false

    document = Cache.rehydrate(cached)
    return document if document

    # An entry we can't read is treated as a miss rather than
    # propagating. A Document member added in a later release, a
    # rolling deploy sharing a cache between two versions, or a
    # host configuring a coder that stringifies keys would
    # otherwise turn /oauth/authorize into a 500 for that
    # client_id until the TTL expired.
    Cache.delete(key)
  end

  # Shape is judged BEFORE either cap is touched. Rejecting a URL on
  # its scheme, port, userinfo or fragment costs nothing outbound,
  # so charging it would let a caller spend their own minute budget
  # on requests that never sent a packet — and then be refused a
  # legitimate fetch. Shape rejects are never cached, either:
  # repeating the check is free, while writing an entry per
  # malformed client_id lets a caller fill a shared cache —
  # evicting the host app's own entries — without sending a single
  # packet.
  target = fetch_target(client_id)
  return nil if target.nil?

  # A host that just failed to answer at all is not retried,
  # whatever path or query is hung off it. Keyed by URL alone the
  # negative cache is defeated by appending ?n=1, ?n=2 — each a
  # distinct key and each a valid CIMD reference.
  host = target.host
  return nil if Cache.read(Cache.failure_key(host)) == false

  # Both caps are consulted only on a genuine miss. A cached
  # resolution costs nothing outbound, so charging it against
  # either budget would penalise the common case and make a busy,
  # correctly-configured server throttle itself.
  #
  # Capacity is taken FIRST, and the minute budget is only charged
  # once a slot is held. The other order spends a token on a
  # request that never sent a packet — which turns a squeeze on the
  # slots into a way to drain every victim's own budget while they
  # retry, locking them out past the point where the slots free up.
  outcome = with_fetch_capacity do
    charge_rate_limit(actor) ? Fetcher.call(client_id, target) : RATE_LIMITED
  end

  case outcome
  when CAPACITY_EXCEEDED, RATE_LIMITED
    # Deliberately no cache write of any kind — see the constants.
    nil
  when Array
    # [document, ttl] — the TTL is derived from the document's own
    # HTTP cache headers, clamped by config.
    document, ttl = outcome
    Cache.write(key, document.to_h, ttl) if ttl.positive?
    document
  when HOST_FAILURE
    Cache.write(key, false, Cache::FAILURE_TTL)
    Cache.write(Cache.failure_key(host), false, Cache::FAILURE_TTL)
    nil
  else
    # A document-level failure — 404, malformed JSON, a document
    # naming the wrong client_id. It says nothing about its
    # neighbours, so it must NOT block them: one domain hosting
    # many client documents is the normal CIMD deployment shape,
    # and poisoning the host on a per-document failure would let
    # anyone hold that whole domain offline by requesting a single
    # bogus URL on it once a minute.
    Cache.write(key, false, Cache::FAILURE_TTL)
    nil
  end
end