Skip to main content

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 &registered.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}