oauth_as/cimd.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (C) 2026 Matthew Jackson
3
4//! Client identifier metadata documents (draft-ietf-oauth-client-id-metadata-document-01): a
5//! client identifies itself with an HTTPS URL, and the metadata that would otherwise have come
6//! from an RFC 7591 registration is fetched from that URL instead.
7//!
8//! # EVERY SECTION NUMBER IN THIS MODULE IS -01's
9//!
10//! This module was written against draft revision **-01**, and every bare "section N" in it — in
11//! this header, in [`CimdError`]'s variants, in [`ClientIdUrl::parse`] and in
12//! [`ValidatedClientIdDocument::validate`] — is a section of THAT revision. Revision -02
13//! (2026-07-06) reorganised the document, so eight of the nine anchors this module cites moved:
14//!
15//! | cited here (-01) | same rule in -02 |
16//! |---|---|
17//! | 3, Client Identifier | 3, retitled Client Identifier URL |
18//! | 4, opening text (no redirects, `200` only) | 5, Client Information Discovery |
19//! | 4.1, Client Metadata Document | 4 body, Client ID Metadata Document |
20//! | 4.3, Metadata Discovery Errors | 5.1 |
21//! | 4.4, Metadata Caching | 5.2 |
22//! | 5, Authorization Server Metadata | 6 |
23//! | 6.1, `redirect_uris` and `client_id` | 8.1 |
24//! | 6.5, SSRF | 8.6 |
25//! | 6.6, Maximum Response Size | 8.7 |
26//!
27//! The RULES are unchanged; the NUMBERS are not. A reader checking a citation against the current
28//! draft should map it through the table first, and nothing in this module's behaviour follows
29//! from the renumbering.
30//!
31//! # THE HOST FETCHES. THIS CRATE VALIDATES.
32//!
33//! There is no outbound HTTP request anywhere in this module, and there is not going to be one.
34//! It is the same posture as the rest of the crate: the host supplies the signer, the host
35//! supplies the consent resolver, the host supplies the clock, and the host owns the socket. It is
36//! also the reason `jwks_uri` is recorded as an open gap in [`crate::registration`] rather than
37//! quietly implemented, and the reason a document carrying `jwks` or `jwks_uri` is REFUSED here
38//! rather than accepted with the key thrown away (see [`CimdError::KeyMaterialPresent`]).
39//!
40//! So the seam is: the host GETs the document at the client identifier URL and hands the bytes
41//! here; [`ValidatedClientIdDocument::validate`] decides whether they are a client, and
42//! [`ValidatedClientIdDocument::to_client`] turns them into one.
43//!
44//! # A VALIDATED DOCUMENT IS NOT A CORRECTLY FETCHED DOCUMENT
45//!
46//! That sentence is the whole security story of this module, so it is stated as a list of duties
47//! the host keeps, in the same register [`crate::registration`] uses for its own gaps. Nothing
48//! below can be checked from inside a library that never touches the network, and a green result
49//! from this module says nothing about any of it:
50//!
51//! - **The fetch itself.** A `GET` with `Accept: application/json`.
52//! - **Section 4: the AUTHORIZATION SERVER MUST NOT automatically follow HTTP redirects.** The
53//! duty is the fetcher's, not the client's, and the rule is in section 4's opening text rather
54//! than in any of its subsections. A crate that never fetches cannot enforce it, and it is the
55//! requirement most likely to be missed, because every HTTP client library follows redirects by
56//! default. See [`ValidatedClientIdDocument::validate`] for the one part of it this module CAN
57//! catch, and for exactly how far that goes.
58//! - **Section 4: `200` only.** Also section 4's opening text, and also addressed to the
59//! authorization server: every other status is an error, including a `3xx` the host declined to
60//! follow.
61//! - **Section 4.3: on fetch failure, abort the authorization request.** Do not fall back to a
62//! cached document, and do not fall back to a registration.
63//! - **Section 4.4: never cache an error response, and never cache an invalid or malformed
64//! document.** The API makes this the easy path rather than a rule to remember: a
65//! [`ValidatedClientIdDocument`] cannot be constructed from a failure, so the only value there
66//! is to cache is one that passed.
67//! - **DNS resolution, and the rebinding window.** See [`ClientIdUrl::parse`]: this module refuses
68//! a special-use IP LITERAL, which is the half of section 6.5 that needs no resolver. It cannot
69//! resolve a name, so it cannot discharge section 6.5, and a host that checks an address and
70//! then connects by NAME has not discharged it either. Resolve once, check THAT address, and
71//! connect to THAT address.
72//! - **TLS.** Certificate verification is the host's, as everywhere else in this crate.
73//!
74//! # What a host does with the result
75//!
76//! [`ValidatedClientIdDocument::to_client`] produces a [`Client`] whose `client_id` is the URL,
77//! and the host installs it in its own [`crate::store::Storage`] for the life of the
78//! authorization request (subject to section 4.4's caching rules, which are the host's). From
79//! that point every other endpoint in this crate treats it as any other client: the redirect URI
80//! is matched exactly, the scope ceiling applies, and PKCE is required.
81
82use serde::{Deserialize, Serialize};
83
84use crate::client::{Client, ClientAuth, ClientId};
85use crate::registration::{
86 ClientMetadata, RegistrationConfig, RegistrationErrorCode, RegistrationErrorResponse,
87 RegistrationFailure,
88};
89use crate::scope::ScopeSet;
90
91/// The recommended maximum size of a client identifier metadata document, in bytes (section 6.6).
92///
93/// # Why the crate publishes it rather than only enforcing it
94///
95/// Enforcing it HERE, on bytes the host has already read into memory, does not prevent the read;
96/// it only stops an oversized document becoming a client. The place a size limit actually costs an
97/// attacker something is at the socket, and that socket belongs to the host. So the number is
98/// public: a host caps its own response reader at this value, and this module then re-checks what
99/// it is handed, because a cap the host forgot must not be a cap nobody applied.
100///
101/// The document is attacker-influenced input by construction — anyone who can publish a page can
102/// publish one of these — which is why it is bounded at all, on the same reasoning as
103/// [`crate::MAX_REGISTERED_REDIRECT_URIS`] and (under `rar`) `MAX_AUTHORIZATION_DETAILS_BYTES`.
104pub const MAX_CLIENT_ID_DOCUMENT_BYTES: usize = 5120;
105
106/// The largest client identifier URL this crate will parse, in bytes.
107///
108/// The draft sets no bound. This one exists because the client identifier arrives as a `client_id`
109/// authorization request parameter, which is to say as unauthenticated text of an attacker's
110/// chosen length, and every check in [`ClientIdUrl::parse`] is a scan over it. 2048 is the
111/// conventional URL ceiling that intermediaries have imposed for decades; a client identifier
112/// anywhere near it is not a URL anybody typed.
113pub const MAX_CLIENT_ID_URL_BYTES: usize = 2048;
114
115/// Why a client identifier URL, or a document fetched from one, was refused.
116///
117/// Each variant names the RULE it broke rather than echoing the offending value: this is
118/// attacker-supplied text that a host is likely to log, and the same reasoning already applies to
119/// [`crate::RegistrationErrorResponse`]'s descriptions.
120#[derive(Debug, Clone, PartialEq, Eq)]
121/// `#[non_exhaustive]`: this enum is a list of REFUSAL REASONS for a specification still in
122/// working-group draft, so it will gain variants as the draft gains rules, and a host that matches
123/// it exhaustively must not have its build broken by a patch release that tightens a check.
124#[non_exhaustive]
125pub enum CimdError {
126 /// Section 3: the client identifier MUST use the `https` scheme.
127 ///
128 /// Spelled in lower case, and an upper-case `HTTPS://` lands here too. Section 4.1 compares
129 /// the document's `client_id` to the fetch URL by RFC 3986 section 6.2.1 SIMPLE STRING
130 /// COMPARISON, so normalising the scheme here would make two byte-distinct strings name one
131 /// client and break that comparison rather than help it. Refusing is the only move that keeps
132 /// both rules true at once.
133 NotHttps,
134 /// RFC 3986 section 3.2: the client identifier has no host. `https:///app` is not a URL
135 /// anything can be fetched from, and accepting one would put an identifier in the client table
136 /// that no fetch could ever be made against.
137 NoHost,
138 /// RFC 3986 section 2: the client identifier contains a byte outside printable ASCII — or a
139 /// backslash or a percent sign in its AUTHORITY, which are printable ASCII and land here
140 /// anyway.
141 ///
142 /// A URI's grammar is ASCII, and anything outside it MUST be percent-encoded, so a raw space,
143 /// a control byte or a newline here is not a URI at all. This crate's RFC 8707 resource
144 /// indicator check makes the same refusal for the same reason. It matters more here than
145 /// there: the client identifier is echoed into audit records, and a newline in one is log
146 /// injection.
147 ///
148 /// # The two printable-ASCII cases, and why they are here
149 ///
150 /// A backslash in the authority (`https://good.example\.evil.com/app`) and a percent-escape in
151 /// the authority (`https://127.0.0.1%2e/app`) are both refused as `NotAscii`. Neither is a
152 /// non-ASCII byte, and the name is a variant REUSED rather than earned: it is one refusal
153 /// short of honest, and it is said here because docs.rs is where a host reads what this
154 /// variant means.
155 ///
156 /// They share the reason. A WHATWG URL parser — which is what every mainstream HTTP client
157 /// puts between the host and the socket — reads a backslash as a path separator and
158 /// percent-DECODES the host before parsing it, while this crate reads the raw bytes. So the
159 /// crate and the fetcher would derive DIFFERENT hosts from one string, which is the entire
160 /// class of defect [`CimdError::SpecialUseAddress`]'s literal check exists to close. Refused
161 /// rather than decoded, for the reason the module refuses everywhere else: decoding would mean
162 /// two parties each deriving a host by their own rules. See [`ClientIdUrl::parse`] for the
163 /// worked examples.
164 NotAscii,
165 /// Section 3: the client identifier MUST contain a path component. `https://client.example`
166 /// has none; `https://client.example/` has one.
167 NoPath,
168 /// Section 3: the client identifier MUST NOT contain single-dot or double-dot path segments.
169 ///
170 /// Refused rather than resolved, for the reason in [`CimdError::NotHttps`]: resolving `..`
171 /// would let two distinct strings name one client while section 4.1 still compares them byte
172 /// for byte.
173 DotSegment,
174 /// Section 3: the client identifier MUST NOT contain a fragment component.
175 Fragment,
176 /// Section 3: the client identifier MUST NOT contain a userinfo component. `https://a@b/c` is
177 /// a URL whose HOST is `b`, which is not what most readers of it see.
178 Userinfo,
179 /// Section 3 SHOULD NOT: the client identifier carries a query string and this deployment did
180 /// not set [`CimdPolicy::allow_query_string`].
181 QueryString,
182 /// Section 6.5: the host of the client identifier is an IP LITERAL in a special-use range
183 /// (RFC 6890), so dereferencing it would be a request to this deployment's own network.
184 ///
185 /// This is only HALF of section 6.5. See [`ClientIdUrl::parse`] for the half that is the
186 /// host's, and for why an address that passes here can still be a rebinding attack.
187 SpecialUseAddress,
188 /// The client identifier is longer than [`MAX_CLIENT_ID_URL_BYTES`].
189 UrlTooLong,
190 /// Section 6.6: the document is larger than the policy's `max_document_bytes`.
191 DocumentTooLarge,
192 /// Section 4.1: the document is not the JSON object RFC 8259 defines.
193 NotJson,
194 /// Section 4.1: the document has no `client_id` member. It is REQUIRED, and its absence is
195 /// not the same as a mismatch: nothing was claimed at all.
196 MissingClientId,
197 /// Section 4.1: the document's `client_id` is not, byte for byte, the URL it was fetched from.
198 ///
199 /// THIS IS THE CHECK THE WHOLE MECHANISM RESTS ON. Without it any document authorizes any
200 /// client: an attacker publishes a document at a URL they control that claims somebody else's
201 /// client identifier, and an authorization server that skipped this comparison hands them that
202 /// client's redirect URIs.
203 ClientIdMismatch,
204 /// Section 4.1: the document carries `client_secret` or `client_secret_expires_at`.
205 ///
206 /// The document is world-readable by construction, so a shared secret in one is a secret
207 /// published to the internet. Refused rather than dropped, on the same reasoning
208 /// [`crate::registration`] gives for `software_statement`: dropping a member the client
209 /// believes is being honoured registers a client on terms nobody agreed to.
210 ClientSecretPresent,
211 /// Section 4.1: `token_endpoint_auth_method` names a method that rests on a SHARED SYMMETRIC
212 /// secret (`client_secret_basic`, `client_secret_post`, `client_secret_jwt`), which a public
213 /// document cannot hold. See [`CimdError::ClientSecretPresent`].
214 SharedSecretAuthMethod,
215 /// Section 4.1: the document carries `jwks` or `jwks_uri`.
216 ///
217 /// REFUSED, NOT DROPPED, and this is the one refusal in the list that is a property of THIS
218 /// BUILD rather than of the draft. The draft PERMITS these two: a public key is the only
219 /// credential a world-readable document can carry, so it is the sanctioned way for a client
220 /// identifier metadata document to say "I authenticate, and here is what with".
221 ///
222 /// This crate cannot honour that yet. [`crate::registration`] models neither member (it
223 /// records the gap in its own module docs), which is why
224 /// [`ValidatedClientIdDocument::to_client`] is unconditionally [`ClientAuth::Public`], and why
225 /// [`crate::registration`]'s own validator refuses `private_key_jwt` outright. A document offering a
226 /// key would therefore have been accepted as a PUBLIC client with no word said about the
227 /// credential its author believes is in force — the exact outcome
228 /// [`CimdError::ClientSecretPresent`] exists to prevent, and there is no reading on which the
229 /// same facts deserve opposite treatment because one member is a secret and the other is not.
230 ///
231 /// It also happens to be the only place the draft's MUST NOT on PRIVATE key material is
232 /// reachable at all: a private JWK arrives inside `jwks`, so a document that publishes one is
233 /// refused here rather than parsed and silently dropped. Nothing else in this module looks
234 /// inside the member, and nothing needs to: the whole member is refused either way.
235 ///
236 /// This variant is where the gap closes. When `jwks`/`jwks_uri` become registrable, a document
237 /// carrying one stops being a refusal and starts being a confidential client — and this
238 /// enum is `#[non_exhaustive]` precisely so that removing a refusal is a patch release.
239 KeyMaterialPresent,
240 /// Section 6.1: a `redirect_uris` entry is not same-origin with the client identifier, and
241 /// this deployment left [`CimdPolicy::redirect_uris_same_origin`] on.
242 ///
243 /// Section 6.1 PERMITS rather than requires this, and it is on by default here because
244 /// without it anyone who can host a document can name any redirect URI in it.
245 RedirectUriNotSameOrigin,
246 /// The document's metadata failed the same RFC 7591 section 2 validation a dynamic
247 /// registration does, and this is that refusal, unchanged.
248 ///
249 /// Section 4.1 says the members come from the OAuth Dynamic Client Registration Metadata
250 /// registry, so they are checked by [`crate::registration`]'s validator rather than by a
251 /// second copy of it that would drift.
252 Metadata(crate::registration::RegistrationErrorResponse),
253}
254
255impl std::fmt::Display for CimdError {
256 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
257 match self {
258 CimdError::NotHttps => f.write_str("the client identifier must use the https scheme"),
259 CimdError::NoHost => f.write_str("the client identifier has no host"),
260 CimdError::NotAscii => f.write_str(
261 "the client identifier contains a byte outside printable ASCII, which RFC 3986 \
262 requires to be percent-encoded, or a backslash or percent sign in its authority, \
263 which a URL parser would read as a different host than this crate does",
264 ),
265 CimdError::NoPath => {
266 f.write_str("the client identifier must contain a path component")
267 }
268 CimdError::DotSegment => f.write_str(
269 "the client identifier must not contain single-dot or double-dot path segments",
270 ),
271 CimdError::Fragment => {
272 f.write_str("the client identifier must not contain a fragment")
273 }
274 CimdError::Userinfo => {
275 f.write_str("the client identifier must not contain a userinfo component")
276 }
277 CimdError::QueryString => f.write_str(
278 "the client identifier carries a query string, which this deployment does not allow",
279 ),
280 CimdError::SpecialUseAddress => f.write_str(
281 "the client identifier names a special-use IP address literal (RFC 6890)",
282 ),
283 CimdError::UrlTooLong => f.write_str("the client identifier is too long"),
284 CimdError::DocumentTooLarge => {
285 f.write_str("the client identifier metadata document is too large")
286 }
287 CimdError::NotJson => {
288 f.write_str("the client identifier metadata document is not a JSON object")
289 }
290 CimdError::MissingClientId => f.write_str(
291 "the client identifier metadata document has no client_id member",
292 ),
293 CimdError::ClientIdMismatch => f.write_str(
294 "the document's client_id is not the URL it was fetched from",
295 ),
296 CimdError::ClientSecretPresent => f.write_str(
297 "a client identifier metadata document must not carry a client secret",
298 ),
299 CimdError::SharedSecretAuthMethod => f.write_str(
300 "token_endpoint_auth_method names a shared-secret method, which a public document \
301 cannot hold",
302 ),
303 CimdError::KeyMaterialPresent => f.write_str(
304 "the document carries jwks or jwks_uri, and this server cannot register a client \
305 key, so honouring the document would mean registering a public client instead",
306 ),
307 CimdError::RedirectUriNotSameOrigin => f.write_str(
308 "a redirect_uri is not same-origin with the client identifier",
309 ),
310 CimdError::Metadata(e) => write!(f, "{e}"),
311 }
312 }
313}
314
315impl std::error::Error for CimdError {}
316
317/// What this deployment will accept from a client identifier metadata document, and how far it
318/// will bend the draft's SHOULDs.
319///
320/// Every knob here is a CEILING or a strictness switch, and the defaults are the strict ones, on
321/// the same reasoning as [`RegistrationConfig::new`]: an unregistered client that anyone on the
322/// internet can mint by publishing a file is exactly the shape RFC 7591 section 5 warns about, and
323/// widening it should be a sentence the host wrote.
324#[derive(Debug, Clone, PartialEq, Eq)]
325/// `#[non_exhaustive]`: this is a policy object for a working-group draft, so it will gain fields
326/// as the draft settles. A host writes a full struct literal today and has a build that breaks on
327/// a patch release; `new()` plus assignment does not. The attribute cannot be added after
328/// publication, because by then the literal is in somebody's production tree.
329#[non_exhaustive]
330pub struct CimdPolicy {
331 /// Section 6.5's carve-out: whether a LOOPBACK literal (`127.0.0.0/8`, `::1`) may be a client
332 /// identifier host.
333 ///
334 /// `false` by default. Section 6.5 permits it only when the authorization server itself runs
335 /// on that same loopback interface, which is the development case and nothing else; in any
336 /// deployment where the AS is reachable from elsewhere, a loopback client identifier is a
337 /// request the AS makes to ITSELF on somebody else's behalf.
338 ///
339 /// Note what turning it on does NOT do: it relaxes the LITERAL check only. Every other
340 /// special-use range stays refused, and a NAME that resolves to loopback is not seen here at
341 /// all — see [`ClientIdUrl::parse`].
342 pub allow_loopback: bool,
343 /// Section 6.6: the largest document, in bytes, this deployment will validate.
344 /// [`MAX_CLIENT_ID_DOCUMENT_BYTES`] by default, which is the draft's recommendation.
345 pub max_document_bytes: usize,
346 /// Section 6.1: whether every `redirect_uris` entry must be same-origin with the client
347 /// identifier URL.
348 ///
349 /// `true` by default. The draft PERMITS the requirement rather than imposing it, and the
350 /// default is on because the alternative is that anyone who can host a document can name any
351 /// redirect URI in it. A deployment that must serve native clients (a custom scheme, or a
352 /// loopback redirect) turns it off knowing what it costs.
353 pub redirect_uris_same_origin: bool,
354 /// Section 3 SHOULD NOT: whether a client identifier may carry a query string.
355 ///
356 /// `false` by default, which is the SHOULD NOT honoured. A query string on an identity makes
357 /// two identifiers that differ only in parameter order two different clients under section
358 /// 4.1's byte comparison while naming one document to most servers.
359 pub allow_query_string: bool,
360 /// The ceiling on what a document may ask for: which grants, which scopes.
361 ///
362 /// The SAME type RFC 7591 dynamic registration uses, deliberately. Section 4.1 says the
363 /// document's members come from the OAuth Dynamic Client Registration Metadata registry, so
364 /// the values being bounded are the same values, and this crate holds one validator for them
365 /// (see [`crate::registration`]). Three of its fields are not read on this path and have no
366 /// meaning here: `registration_endpoint`, `client_secret_ttl` (nothing issues a secret to a
367 /// CIMD client) and `management_enabled` (there is no registration to manage; the client
368 /// edits its own document).
369 pub registration_bounds: RegistrationConfig,
370}
371
372impl Default for CimdPolicy {
373 fn default() -> Self {
374 CimdPolicy::new()
375 }
376}
377
378impl CimdPolicy {
379 /// The strict defaults: no loopback, the draft's 5 KB cap, same-origin redirect URIs, no query
380 /// string, and [`RegistrationConfig::new`]'s narrow grant and scope ceilings.
381 pub fn new() -> Self {
382 CimdPolicy {
383 allow_loopback: false,
384 max_document_bytes: MAX_CLIENT_ID_DOCUMENT_BYTES,
385 redirect_uris_same_origin: true,
386 allow_query_string: false,
387 registration_bounds: RegistrationConfig::new(),
388 }
389 }
390}
391
392/// A client identifier URL that has passed section 3's syntax rules and section 6.5's literal
393/// address check.
394///
395/// The inner string is PRIVATE, which is the point of the type: the only way to hold one is to
396/// have called [`ClientIdUrl::parse`], so a value of this type cannot be a URL nobody checked.
397/// Nothing normalises it, ever — see [`ValidatedClientIdDocument::validate`] for why normalisation
398/// would be the bug rather than the fix.
399///
400/// # There is no `Deserialize`, deliberately
401///
402/// It derived one until it was caught in review, and that made the paragraph above FALSE: a
403/// derived `Deserialize` on a newtype is a direct `String` to `ClientIdUrl` conversion, so
404/// `serde_json::from_str("\"http://evil.example\"")` produced a value of this type that had passed
405/// none of the section 3 or 6.5 checks — not https, not the length cap, not the userinfo,
406/// fragment, dot-segment or special-use-address refusals. `validate` then compared the document's
407/// `client_id` against that unchecked string, and byte equality with an unchecked URL proves
408/// nothing.
409///
410/// It cannot be fixed by deserializing THROUGH `parse`, because `parse` takes a [`CimdPolicy`] and
411/// serde has none to give it: two of the rules ([`CimdPolicy::allow_loopback`],
412/// [`CimdPolicy::allow_query_string`]) are the host's decision, so a `Deserialize` would have to
413/// pick a policy and would be wrong for every host that chose the other one.
414///
415/// **So a host that caches validated documents caches the URL as a STRING and calls
416/// [`ClientIdUrl::parse`] on the way back in, with its own policy.** That is one line, it is the
417/// only construction path, and it is what makes the invariant above true rather than aspirational.
418/// `Serialize` is kept: writing out a value that has already been checked is safe, and it is what
419/// a cache needs to store.
420#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
421pub struct ClientIdUrl(String);
422
423impl ClientIdUrl {
424 /// Check `raw` against section 3's syntax rules and the half of section 6.5 that needs no
425 /// resolver. NO I/O, and no normalisation.
426 ///
427 /// # What is checked
428 ///
429 /// Section 3, every clause:
430 ///
431 /// - MUST be `https`, spelled in lower case ([`CimdError::NotHttps`]);
432 /// - MUST have a path component ([`CimdError::NoPath`]);
433 /// - MUST NOT contain single-dot or double-dot path segments ([`CimdError::DotSegment`]);
434 /// - MUST NOT contain a fragment ([`CimdError::Fragment`]);
435 /// - MUST NOT contain userinfo ([`CimdError::Userinfo`]);
436 /// - SHOULD NOT contain a query string, which is [`CimdPolicy::allow_query_string`];
437 /// - MAY contain a port, so a port is not a reason to refuse.
438 ///
439 /// Plus four rules section 3 does not spell out:
440 ///
441 /// - there must BE a host: `https:///app` is [`CimdError::NoHost`], because an identifier
442 /// nothing can be fetched from is not one;
443 /// - no backslash in the authority ([`CimdError::NotAscii`]), because a WHATWG URL parser
444 /// reads it as a path separator and so connects to a different host than this crate checked;
445 /// - no percent-escape in the authority ([`CimdError::NotAscii`]), because such a parser
446 /// percent-decodes the host before reading it, and the two would again disagree;
447 /// - [`MAX_CLIENT_ID_URL_BYTES`].
448 ///
449 /// Plus section 6.5 as far as a literal goes — including an IPv4 address spelled any way but
450 /// the canonical dotted-quad, which is [`CimdError::SpecialUseAddress`].
451 ///
452 /// # What section 6.5 still leaves with the host
453 ///
454 /// Section 6.5 requires that the URL does not RESOLVE to a special-use address. This function
455 /// refuses a special-use IP LITERAL, which is the only part of that a library with no resolver
456 /// can decide. Two things remain, and both are the host's:
457 ///
458 /// 1. **Resolution.** `https://internal.corp.example/app` passes here and may resolve to
459 /// `10.0.0.1`.
460 /// 2. **The rebinding window.** A host that resolves the name, checks the address, and then
461 /// hands the NAME to its HTTP client has checked one answer and connected to another. The
462 /// honest instruction is: resolve once, check that address, connect to THAT address.
463 ///
464 /// A `Ok` from this function is therefore not a statement that dereferencing the URL is safe.
465 pub fn parse(raw: &str, policy: &CimdPolicy) -> Result<Self, CimdError> {
466 if raw.len() > MAX_CLIENT_ID_URL_BYTES {
467 return Err(CimdError::UrlTooLong);
468 }
469 // RFC 3986 s2: a URI is ASCII, and everything outside the grammar is percent-encoded. Same
470 // rule, and the same range, as `crate::authorization::is_valid_resource_indicator`.
471 if !raw.bytes().all(|b| (0x21..=0x7e).contains(&b)) {
472 return Err(CimdError::NotAscii);
473 }
474 // Lower case, and byte-exact. See `CimdError::NotHttps`: section 4.1's comparison is a
475 // simple string comparison, so a scheme this crate case-folded would be a scheme two
476 // distinct client identifiers could share.
477 let rest = raw.strip_prefix("https://").ok_or(CimdError::NotHttps)?;
478 // A fragment is checked over the WHOLE string rather than over the path, because `#`
479 // terminates everything after it in RFC 3986 section 3.5 regardless of where it appears.
480 if raw.contains('#') {
481 return Err(CimdError::Fragment);
482 }
483 // The authority runs to the first `/`, `?` or `#`; there is no `#` left by now.
484 let authority_end = rest.find(['/', '?']).unwrap_or(rest.len());
485 let authority = &rest[..authority_end];
486 if authority.contains('@') {
487 return Err(CimdError::Userinfo);
488 }
489 // Section 3: a path component is REQUIRED, so a bare origin is not a client identifier.
490 // `https://client.example` has no path at all and lands here; `https://client.example/`
491 // has the path `/` and does not. That is the RFC 3986 section 3.3 reading: `path-abempty`
492 // may be empty, and "contains a path component" is the statement that it is not.
493 if authority_end == rest.len() || rest.as_bytes()[authority_end] != b'/' {
494 return Err(CimdError::NoPath);
495 }
496 let after_authority = &rest[authority_end..];
497 let (path, query) = match after_authority.find('?') {
498 Some(at) => (&after_authority[..at], Some(&after_authority[at + 1..])),
499 None => (after_authority, None),
500 };
501 // Section 3: no single-dot or double-dot segments. Segment-wise, not substring-wise: a
502 // path component that merely CONTAINS a dot (`/v1.2/client`) is fine, and only a whole
503 // segment equal to `.` or `..` is not.
504 if path
505 .split('/')
506 .any(|segment| segment == "." || segment == "..")
507 {
508 return Err(CimdError::DotSegment);
509 }
510 if query.is_some() && !policy.allow_query_string {
511 return Err(CimdError::QueryString);
512 }
513 // Section 6.5, the literal half. A port is explicitly permitted by section 3, so it is
514 // stripped before the address is read rather than treated as part of it. An authority with
515 // no host at all is refused rather than skipped: `host_of` returning `None` used to mean
516 // "no address to check", which read as "nothing to refuse".
517 let host = host_of(authority).ok_or(CimdError::NoHost)?;
518 // AN IPv4 ADDRESS SPELLED ANY WAY BUT THE CANONICAL ONE IS REFUSED, and this is the check
519 // that makes the one below mean anything.
520 //
521 // `is_special_use_literal` decides "is this a literal" with `Ipv4Addr::from_str`, which
522 // accepts ONLY canonical dotted-quad. The host that actually performs the fetch does not:
523 // every mainstream HTTP client goes through a WHATWG URL parser, which accepts decimal,
524 // hexadecimal, octal and short forms and normalises them all to the same address. So the
525 // two disagreed, and the crate was refusing the one spelling an attacker would never use:
526 //
527 // https://127.0.0.1/app refused
528 // https://2130706433/app ACCEPTED, fetches 127.0.0.1
529 // https://0x7f000001/app ACCEPTED, fetches 127.0.0.1
530 // https://0177.0.0.1/app ACCEPTED, fetches 127.0.0.1
531 // https://127.1/app ACCEPTED, fetches 127.0.0.1
532 // https://127.0.0.1./app ACCEPTED, fetches 127.0.0.1
533 //
534 // That is the request section 6.5 exists to prevent, reaching the deployment's own network,
535 // and this module's docs tell the host the LITERAL half has been discharged and only name
536 // resolution is left to them. `0x7f000001` is not a name.
537 //
538 // The rule is WHATWG's own: a host "ends in a number" -- and is therefore an IPv4 address
539 // rather than a name -- when its last non-empty label is all digits, or is `0x`-prefixed
540 // hex. Such a host must be a canonical dotted-quad, so that the check below reads the same
541 // address the fetcher will connect to. Anything else is refused rather than normalised,
542 // because normalising an address the caller wrote ambiguously is guessing at intent on the
543 // one input where guessing wrong reaches inside the network.
544 // NOT for an IPv6 literal. `host_of` has stripped the brackets by here, so a host that
545 // still contains a colon is v6, and WHATWG's "ends in a number" rule is part of the
546 // IPv4/opaque-host path -- a v6 literal has its own parser and never reaches it. Applying
547 // it here refused `::ffff:93.184.216.34`, a PUBLIC address, because its last dot-label is
548 // `34` and `Ipv4Addr::from_str` then fails on the colons. The same address spelled
549 // `::ffff:5db8:d822` was accepted, so the verdict depended on the spelling rather than on
550 // the address. Worse, it short-circuited the v4-mapped branch of `is_special_use_literal`
551 // for every dotted form, which is why the 0.9.2 sweep left seventeen survivors in there
552 // and why the test asserting `[::ffff:169.254.169.254]` is refused was passing on this
553 // rule rather than on the one it names.
554 if !host.contains(':')
555 && ends_in_a_number(host)
556 && host.parse::<std::net::Ipv4Addr>().is_err()
557 {
558 return Err(CimdError::SpecialUseAddress);
559 }
560 // A BACKSLASH IS A SLASH TO THE FETCHER AND NOT TO US, which makes it the same class of
561 // defect one character wider. In `https://good.example\.evil.com/app` this crate reads the
562 // authority as `good.example\.evil.com` -- so `origin()`, the same-origin redirect rule and
563 // the byte-equality check are all computed against that -- while a WHATWG parser treats the
564 // backslash as a path separator and connects to `good.example`, path `/.evil.com/app`.
565 // Two parties, two different hosts, one string. Refused outright: there is no legitimate
566 // client identifier with a backslash in it, and RFC 3986 does not admit one in an authority.
567 if authority.contains('\\') {
568 return Err(CimdError::NotAscii);
569 }
570 // A PERCENT-ESCAPE IN THE AUTHORITY IS THE SAME DEFECT ONE ENCODING LAYER DOWN, and the
571 // first version of the check above missed it. `ends_in_a_number` reads the raw text; a
572 // WHATWG host parser percent-DECODES the host before it applies that rule, so the two read
573 // different strings -- which is exactly what this whole check exists to stop.
574 //
575 // https://%31%36%39%2e%32%35%34%2e%31%36%39%2e%32%35%34/app
576 //
577 // was ACCEPTED, and `curl` on that URL connects to 169.254.169.254, the cloud metadata
578 // service. `https://127.0.0.1%2e/app` was accepted the same way, because encoding only the
579 // trailing dot defeats the last-label test.
580 //
581 // REFUSED rather than decoded-then-checked, for the reason the module refuses everywhere
582 // else: decoding here would mean this crate and the fetcher each deriving a host from the
583 // same bytes by their own rules, and the entire class of defect is those two answers
584 // differing. A percent-escape has no legitimate place in a client identifier's authority
585 // -- an internationalised name arrives as punycode (`xn--`), which is ASCII already, and
586 // section 4.1's byte-for-byte `client_id` comparison means an escaped form could not match
587 // its own document anyway.
588 if authority.contains('%') {
589 return Err(CimdError::NotAscii);
590 }
591 if is_special_use_literal(host, policy.allow_loopback) {
592 return Err(CimdError::SpecialUseAddress);
593 }
594 Ok(ClientIdUrl(raw.to_string()))
595 }
596
597 /// The identifier, exactly as it was given. This is the string section 4.1 compares against,
598 /// and the string that becomes the [`ClientId`].
599 pub fn as_str(&self) -> &str {
600 &self.0
601 }
602
603 /// The origin: scheme, host and port, with no trailing slash. Used for section 6.1's
604 /// same-origin redirect URI rule.
605 fn origin(&self) -> &str {
606 // TOTAL, and it does not rest on "safe by construction" any more. It used to slice at a
607 // fixed byte 8 with a comment saying `parse` had refused anything shorter, which was true
608 // of every value `parse` produced and false of one built any other way. A derived
609 // `Deserialize` (since removed, see the type's docs) made `ClientIdUrl("x")` reachable, and
610 // this line then panicked with "start byte index 8 is out of bounds for string of length
611 // 1" -- inside a library, on a path the DEFAULT policy reaches, which is a process abort in
612 // the host rather than an `Err`.
613 //
614 // The derive is gone, so the bad value is unreachable today. This stays total anyway: a
615 // function that is correct only because of an invariant enforced somewhere else is one
616 // refactor away from being wrong again, and the cost of not depending on that is a
617 // `strip_prefix`.
618 let Some(rest) = self.0.strip_prefix("https://") else {
619 return &self.0;
620 };
621 let end = rest.find(['/', '?']).unwrap_or(rest.len());
622 &self.0[.."https://".len() + end]
623 }
624}
625
626impl std::fmt::Display for ClientIdUrl {
627 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
628 f.write_str(&self.0)
629 }
630}
631
632/// Whether a WHATWG URL parser would read this host as an IPv4 ADDRESS rather than as a name.
633///
634/// The rule is "ends in a number": take the last label, ignoring one trailing dot, and the host is
635/// an address when that label is entirely ASCII digits, or is `0x`/`0X` followed by hex digits (or
636/// by nothing at all -- bare `0x` is the number zero to that parser). This is what makes
637/// `2130706433`, `0x7f000001`, `0177.0.0.1` and `127.1` all mean 127.0.0.1 to the client that
638/// fetches, while `Ipv4Addr::from_str` calls none of them an address.
639///
640/// A bracketed IPv6 literal is MOSTLY not this function's business — `host_of` has already
641/// stripped the brackets, and the `:` makes it unmistakable to the check that follows — with one
642/// spelling that is. An IPv4-embedded address (`[::ffff:127.0.0.1]`) has a last dot-label of `1`,
643/// so this returns `true`, `Ipv4Addr::from_str` then fails on the colons, and
644/// [`ClientIdUrl::parse`] refuses the URL as [`CimdError::SpecialUseAddress`]. That outcome is
645/// fail-closed and harmless — the address IS special-use — but it is reached by a rule wearing
646/// somebody else's name, so it is written down rather than left to be rediscovered.
647fn ends_in_a_number(host: &str) -> bool {
648 // One trailing dot is legal in a DNS name and is dropped by the parser, so `127.0.0.1.` is the
649 // same address as `127.0.0.1` and must not slip past by looking like an empty last label.
650 let host = host.strip_suffix('.').unwrap_or(host);
651 let Some(last) = host.rsplit('.').next() else {
652 return false;
653 };
654 if last.is_empty() {
655 return false;
656 }
657 if let Some(hex) = last.strip_prefix("0x").or_else(|| last.strip_prefix("0X")) {
658 return hex.chars().all(|c| c.is_ascii_hexdigit());
659 }
660 last.chars().all(|c| c.is_ascii_digit())
661}
662
663/// The host of an authority, with any port and any IPv6 brackets removed. `None` when the
664/// authority is empty.
665fn host_of(authority: &str) -> Option<&str> {
666 if authority.is_empty() {
667 return None;
668 }
669 // An IPv6 literal is bracketed (RFC 3986 section 3.2.2), and only then may its host contain
670 // `:`, so the bracket case has to be settled before the port is split off.
671 if let Some(rest) = authority.strip_prefix('[') {
672 return rest.split(']').next().filter(|h| !h.is_empty());
673 }
674 authority.split(':').next().filter(|h| !h.is_empty())
675}
676
677/// Whether `host` is an IP address LITERAL in one of RFC 6890's special-purpose ranges.
678///
679/// A name is not a literal and returns `false`, which is not an assertion that the name is safe:
680/// see [`ClientIdUrl::parse`] for what that leaves with the host.
681///
682/// Written out by octet and by segment rather than over `std::net`'s predicates, because the ones
683/// that would cover this (`is_documentation`, `is_shared`, `is_benchmarking`, `is_global`) are
684/// still unstable, and this crate's MSRV is 1.75. Writing them out also lets each range name the
685/// RFC 6890 entry it is, which a predicate call does not.
686fn is_special_use_literal(host: &str, allow_loopback: bool) -> bool {
687 use std::net::{Ipv4Addr, Ipv6Addr};
688
689 if let Ok(v4) = host.parse::<Ipv4Addr>() {
690 let o = v4.octets();
691 // 127.0.0.0/8, loopback. The one range a policy may re-admit (section 6.5's carve-out for
692 // an AS that itself runs on loopback).
693 if o[0] == 127 {
694 return !allow_loopback;
695 }
696 return match o {
697 // "This host on this network", RFC 1122 s3.2.1.3.
698 [0, ..] => true,
699 // Private-use, RFC 1918.
700 [10, ..] => true,
701 [172, b, ..] if (16..=31).contains(&b) => true,
702 [192, 168, ..] => true,
703 // Shared address space (carrier-grade NAT), RFC 6598: 100.64.0.0/10.
704 [100, b, ..] if (64..=127).contains(&b) => true,
705 // Link local, RFC 3927.
706 [169, 254, ..] => true,
707 // IETF protocol assignments, RFC 6890: 192.0.0.0/24.
708 [192, 0, 0, _] => true,
709 // Documentation, RFC 5737.
710 [192, 0, 2, _] | [198, 51, 100, _] | [203, 0, 113, _] => true,
711 // 6to4 relay anycast, RFC 3068.
712 [192, 88, 99, _] => true,
713 // Benchmarking, RFC 2544: 198.18.0.0/15.
714 [198, b, ..] if b == 18 || b == 19 => true,
715 // Multicast (224/4), reserved (240/4) and the limited broadcast address.
716 [a, ..] if a >= 224 => true,
717 _ => false,
718 };
719 }
720
721 if let Ok(v6) = host.parse::<Ipv6Addr>() {
722 let s = v6.segments();
723 // ::1/128, loopback, and the same carve-out as the v4 case above.
724 if v6 == Ipv6Addr::LOCALHOST {
725 return !allow_loopback;
726 }
727 // An IPv4-mapped or IPv4-compatible address is decided by the EMBEDDED v4 address, not by
728 // its v6 spelling: `::ffff:127.0.0.1` is loopback however it is written, and reading it
729 // as "some v6 address in ::ffff:0:0/96" would let every v4 rule above be bypassed by
730 // rewriting the literal.
731 if s[0..5] == [0, 0, 0, 0, 0] && (s[5] == 0xffff || s[5] == 0) {
732 let embedded = Ipv4Addr::new(
733 (s[6] >> 8) as u8,
734 (s[6] & 0xff) as u8,
735 (s[7] >> 8) as u8,
736 (s[7] & 0xff) as u8,
737 );
738 return is_special_use_literal(&embedded.to_string(), allow_loopback);
739 }
740 return match s[0] {
741 // ::/128 unspecified, and everything else in ::/8 that is not the two cases above.
742 0 => true,
743 // 64:ff9b::/96 IPv4-IPv6 translation, RFC 6052.
744 0x0064 => true,
745 // 100::/64 discard-only, RFC 6666.
746 0x0100 => s[1] == 0 && s[2] == 0 && s[3] == 0,
747 // 2001::/23 IETF protocol assignments, RFC 2928, which covers Teredo (2001::/32) and
748 // benchmarking (2001:2::/48); plus 2001:db8::/32 documentation, RFC 3849, which sits
749 // outside that /23 and is listed by RFC 6890 separately.
750 0x2001 => s[1] < 0x0200 || s[1] == 0x0db8,
751 // 2002::/16, 6to4, RFC 3056.
752 0x2002 => true,
753 // fc00::/7 unique local, fe80::/10 link local, ff00::/8 multicast.
754 first => {
755 (first & 0xfe00) == 0xfc00 || (first & 0xffc0) == 0xfe80 || (first >> 8) == 0xff
756 }
757 };
758 }
759
760 false
761}
762
763/// The wire shape of a client identifier metadata document (section 4.1): the RFC 7591 section 2
764/// members, plus `client_id`, plus the members that carry a CREDENTIAL.
765///
766/// The credential members are modelled precisely SO THAT they can be refused. A type that did not
767/// name them would have `serde` drop them silently, and a document whose author put a credential in
768/// it would be accepted as a public client with no word said about the thing they believe is in
769/// force. That reasoning does not care whether the credential is a secret the draft forbids or a
770/// key the draft permits and this build cannot register; see [`CimdError::KeyMaterialPresent`].
771#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
772struct ClientIdDocument {
773 /// REQUIRED (section 4.1). Compared to the fetch URL by simple string comparison.
774 #[serde(default)]
775 client_id: Option<String>,
776 /// MUST NOT be present (section 4.1).
777 #[serde(default)]
778 client_secret: Option<serde_json::Value>,
779 /// MUST NOT be present (section 4.1).
780 #[serde(default)]
781 client_secret_expires_at: Option<serde_json::Value>,
782 /// PERMITTED by section 4.1, and refused by this build: see [`CimdError::KeyMaterialPresent`].
783 /// Modelled as an opaque value rather than a JWK set because nothing here reads INSIDE it —
784 /// the whole member is the refusal, so parsing its contents would only be a second way to
785 /// fail on a document that is already going to be rejected.
786 #[serde(default)]
787 jwks: Option<serde_json::Value>,
788 /// PERMITTED by section 4.1, and refused by this build: see [`CimdError::KeyMaterialPresent`].
789 #[serde(default)]
790 jwks_uri: Option<serde_json::Value>,
791 /// Everything else, which is the RFC 7591 registry, parsed by the type the RFC 7591 endpoint
792 /// already uses.
793 #[serde(flatten)]
794 metadata: ClientMetadata,
795}
796
797/// The shared-symmetric `token_endpoint_auth_method` values section 4.1 forbids.
798///
799/// `client_secret_basic` and `client_secret_post` transmit the secret; `client_secret_jwt` MACs
800/// with it (RFC 7518 section 3.2). All three require the server to hold a secret the client also
801/// holds, and a document anyone can GET cannot establish one.
802const FORBIDDEN_AUTH_METHODS: &[&str] = &[
803 "client_secret_basic",
804 "client_secret_post",
805 "client_secret_jwt",
806];
807
808/// The `token_endpoint_auth_method` a client identifier metadata document actually gets. RFC 7591
809/// section 2's default is `client_secret_basic`, and that default cannot apply here (see
810/// [`ValidatedClientIdDocument::validate`]).
811const AUTH_METHOD_NONE: &str = "none";
812
813/// A document that passed every check in this module. It cannot be constructed any other way.
814///
815/// That is section 4.4's caching rule made structural rather than remembered: the only value a
816/// host can hold is one that validated, so "MUST NOT cache an invalid or malformed document" is
817/// something the type system says rather than something a comment asks for.
818#[derive(Debug, Clone, PartialEq, Eq)]
819pub struct ValidatedClientIdDocument {
820 url: ClientIdUrl,
821 registered: crate::registration::Registered,
822}
823
824impl ValidatedClientIdDocument {
825 /// Validate `body` as the client identifier metadata document fetched from `fetched_from`.
826 ///
827 /// # `fetched_from` is where the bytes CAME FROM, not what was requested
828 ///
829 /// This is the one signature decision in the module worth defending. Section 4.1 compares the
830 /// document's `client_id` to the URL the document was retrieved from, and a host that followed
831 /// a redirect — which section 4 forbids — retrieved it from somewhere else. Passing the URL
832 /// the host actually GOT the bytes from therefore turns the most common redirect violation
833 /// into a [`CimdError::ClientIdMismatch`] instead of a silent acceptance.
834 ///
835 /// It does NOT enforce the no-redirects rule. A redirect chain that ends at the same URL still
836 /// passes, and a host that passes the requested URL after following a redirect elsewhere
837 /// defeats the check entirely. Not following redirects remains the host's duty; this only
838 /// means that getting it wrong is usually caught.
839 ///
840 /// # The comparison is byte equality
841 ///
842 /// RFC 3986 section 6.2.1 SIMPLE STRING COMPARISON, and nothing else. A `client_id` that
843 /// differs from the fetch URL by a trailing slash, by the case of the host, or by
844 /// percent-encoding is REFUSED, not normalised. Normalising any of them would mean two
845 /// distinct strings name one client, which is the property an attacker needs: it is what lets
846 /// a document published at one URL answer for another.
847 ///
848 /// # Every other check, and where it comes from
849 ///
850 /// - Section 6.6: `body` is at most [`CimdPolicy::max_document_bytes`].
851 /// - Section 4.1: the body is a JSON object, and `client_id` is present.
852 /// - Section 4.1: `client_secret` and `client_secret_expires_at` are absent.
853 /// - Section 4.1: `jwks` and `jwks_uri` are absent, which is this BUILD's limit rather than
854 /// the draft's rule; see [`CimdError::KeyMaterialPresent`].
855 /// - Section 4.1: `token_endpoint_auth_method` is not a shared-symmetric method.
856 /// - Section 4.1: everything else is the RFC 7591 section 2 registry, so it goes through
857 /// [`crate::registration`]'s validator — the SAME one, not a copy: redirect URIs must be
858 /// absolute with no fragment and at most [`crate::MAX_REGISTERED_REDIRECT_URIS`] of them,
859 /// `grant_types` and `response_types` must correspond, the scope must be within the policy's
860 /// ceiling, and a `software_statement` is refused.
861 /// - Section 6.1: `redirect_uris` are same-origin with `fetched_from`, unless the policy says
862 /// otherwise.
863 ///
864 /// # The RFC 7591 default this deliberately does not take
865 ///
866 /// RFC 7591 section 2 says an ABSENT `token_endpoint_auth_method` means `client_secret_basic`.
867 /// Applied literally here that would refuse every document that omits the member, because
868 /// section 4.1 forbids exactly that value. So an absent member is read as `none`: a document
869 /// anyone can GET establishes no shared secret, so `none` is the only method it could ever
870 /// have meant, and the resulting client is public. Nothing is accepted-and-ignored by this —
871 /// an EXPLICIT shared-secret method is still a refusal.
872 pub fn validate(
873 fetched_from: &ClientIdUrl,
874 body: &[u8],
875 policy: &CimdPolicy,
876 ) -> Result<Self, CimdError> {
877 // Section 6.6, FIRST, before anything walks the bytes. A cap applied after parsing is a
878 // cap that did not bound the parse.
879 if body.len() > policy.max_document_bytes {
880 return Err(CimdError::DocumentTooLarge);
881 }
882 let document: ClientIdDocument =
883 serde_json::from_slice(body).map_err(|_| CimdError::NotJson)?;
884
885 // Section 4.1, and the reason the whole mechanism is safe. Byte equality; see the doc
886 // comment above for why every tempting normalisation is a defect.
887 let claimed = document
888 .client_id
889 .as_deref()
890 .ok_or(CimdError::MissingClientId)?;
891 if claimed != fetched_from.as_str() {
892 return Err(CimdError::ClientIdMismatch);
893 }
894
895 // Section 4.1's two prohibitions. Refused rather than dropped: see the variant's docs.
896 if document.client_secret.is_some() || document.client_secret_expires_at.is_some() {
897 return Err(CimdError::ClientSecretPresent);
898 }
899
900 // THE SAME RULE APPLIED TO THE MEMBER THE DRAFT PERMITS. `jwks`/`jwks_uri` are the
901 // sanctioned way for one of these documents to carry a credential, and this build cannot
902 // register a client key at all, so accepting one would produce a public client from a
903 // document that asked to be a confidential one. Refused for the reason above it, not for a
904 // different one; see `CimdError::KeyMaterialPresent`.
905 if document.jwks.is_some() || document.jwks_uri.is_some() {
906 return Err(CimdError::KeyMaterialPresent);
907 }
908
909 let mut metadata = document.metadata;
910 match metadata.token_endpoint_auth_method.as_deref() {
911 Some(m) if FORBIDDEN_AUTH_METHODS.contains(&m) => {
912 return Err(CimdError::SharedSecretAuthMethod)
913 }
914 // See the doc comment: RFC 7591 section 2's default cannot apply to a public document.
915 None => metadata.token_endpoint_auth_method = Some(AUTH_METHOD_NONE.to_string()),
916 Some(_) => {}
917 }
918
919 // THE SAME VALIDATOR the RFC 7591 endpoint runs, not a second one. Section 4.1 says the
920 // members come from that registry, so the rules are those rules, and this crate has
921 // already been bitten by one rule living in two places.
922 let registered = crate::registration::validate(&metadata, &policy.registration_bounds)
923 .map_err(|failure| match failure {
924 RegistrationFailure::Invalid(response) => CimdError::Metadata(response),
925 // `validate` returns only `Invalid`; the other variants belong to the ENDPOINT
926 // around it (an absent configuration, a policy refusal, storage), none of which
927 // is reachable from here. Mapped rather than unwrapped, so that a variant added
928 // later cannot become a panic on an attacker-supplied document.
929 _ => CimdError::Metadata(RegistrationErrorResponse::new(
930 RegistrationErrorCode::InvalidClientMetadata,
931 "the document names metadata this server will not accept",
932 )),
933 })?;
934
935 // Section 6.1, last, because it is the one rule that is a POLICY rather than the draft.
936 if policy.redirect_uris_same_origin {
937 let origin = fetched_from.origin();
938 for uri in ®istered.redirect_uris {
939 let same = uri.strip_prefix(origin).is_some_and(|rest| {
940 rest.is_empty() || rest.starts_with('/') || rest.starts_with('?')
941 });
942 if !same {
943 return Err(CimdError::RedirectUriNotSameOrigin);
944 }
945 }
946 }
947
948 Ok(ValidatedClientIdDocument {
949 url: fetched_from.clone(),
950 registered,
951 })
952 }
953
954 /// The client identifier this document belongs to, which is the URL it was fetched from.
955 pub fn client_id_url(&self) -> &ClientIdUrl {
956 &self.url
957 }
958
959 /// The [`Client`] a host installs in its own [`crate::store::Storage`] for this authorization
960 /// request.
961 ///
962 /// Always [`ClientAuth::Public`], and no document that reached here could have been anything
963 /// else. Section 4.1 forbids the shared-secret methods outright; the one credential the draft
964 /// DOES permit — a public key in `jwks`/`jwks_uri` — is refused by
965 /// [`ValidatedClientIdDocument::validate`] rather than dropped, precisely so that this line
966 /// stays true by construction instead of by silently discarding a member. So a CIMD client
967 /// proves possession of nothing and the flow compensates with PKCE exactly as it does for a
968 /// native app. See [`CimdError::KeyMaterialPresent`].
969 ///
970 /// `registration` is `None`. That field means "created by RFC 7591 dynamic registration", and
971 /// this client was not: there is no registration access token, and RFC 7592 read, update and
972 /// delete have no meaning for a client that edits its own document. Setting it would advertise
973 /// a management surface that does not exist.
974 ///
975 /// `default_scopes` is EMPTY rather than the document's `scope`. RFC 6749 section 3.3's server
976 /// default is a deployment's decision about what a request that names no scope receives, and a
977 /// document the client wrote is not a deployment decision; `allowed_scopes` is the ceiling and
978 /// is where the document's `scope` lands.
979 pub fn to_client(&self) -> Client {
980 Client {
981 client_id: ClientId::new(self.url.as_str()),
982 auth: ClientAuth::Public,
983 grant_types: self.registered.grant_types.clone(),
984 redirect_uris: self.registered.redirect_uris.clone(),
985 allowed_scopes: self.registered.scope.clone(),
986 default_scopes: ScopeSet::empty(),
987 name: self.registered.client_name.clone(),
988 registration: None,
989 }
990 }
991}