Skip to main content

protocol_droid/domain/oauth/
registration.rs

1//! Client identity and the credentials that travel with it.
2//!
3//! Two mechanisms, and they are not the same shape. RFC 7591 Dynamic Client
4//! Registration asks an authorization server for an identifier and gets back a
5//! secret the client proves possession of. A Client ID Metadata Document
6//! inverts it: the client publishes a document at an HTTPS URL and uses that URL
7//! as its `client_id`, so there is nothing to register and — the draft says so
8//! directly — "there is no way to establish a shared secret to be used with
9//! client metadata documents". [`ClientSecret`] therefore cannot be constructed
10//! for the document form, rather than being optional and quietly absent.
11//!
12//! `2025-11-25` §8 (SEP-991) adds the document form alongside registration and
13//! recommends it; `2026-07-28` §4 (PR #2858) deprecates registration "in favor
14//! of" documents while keeping it for servers that do not support them. Both
15//! facts are already in
16//! [`ClientRegistration`](crate::domain::mcp::authorization::ClientRegistration);
17//! this module is what a client does with the answer.
18//!
19//! # Why the issuer is part of the identity
20//!
21//! `2026-07-28` §9 (SEP-2352) requires that clients key persisted credentials by
22//! the issuer identifier, never reuse them with a different authorization
23//! server, and re-register when it changes. The rule exists because the revision
24//! removes the other way to tell two servers apart — `Mcp-Session-Id` is gone
25//! (SEP-2567) — so a client that keeps one `client_id` and one secret across
26//! every authorization server it ever meets will hand that secret to whichever
27//! one it is currently talking to, which is exactly the one that must not get
28//! it.
29//!
30//! Making the issuer part of [`RegisteredClient`]'s identity rather than a
31//! lookup key means a credential cannot be retrieved without also asserting
32//! who issued it. See [`decide_registration`].
33
34use core::fmt;
35use std::sync::Arc;
36
37use crate::domain::mcp::authorization::AuthorizationModel;
38
39/// A client's identifier at its authorization server.
40///
41/// Two forms, and they are not interchangeable. RFC 7591 hands out an opaque
42/// string; a Client ID Metadata Document uses the URL the document is served
43/// from. [`verify_metadata_document`](Self::verify_metadata_document) checks
44/// the second form's defining property.
45#[derive(Clone, PartialEq, Eq, Hash)]
46pub struct ClientId(Arc<str>);
47
48impl ClientId {
49    /// An opaque identifier, as RFC 7591's `client_id` response member is.
50    #[must_use]
51    pub fn opaque(value: impl AsRef<str>) -> Self {
52        Self(Arc::from(value.as_ref()))
53    }
54
55    /// The `client_id` of a Client ID Metadata Document.
56    ///
57    /// # Errors
58    ///
59    /// [`ClientIdError::NotHttps`] if the URL is not `https`. The draft
60    /// requires an HTTPS URL, and a `client_id` that is not one is a
61    /// configuration mistake worth naming rather than a value to discover at
62    /// the authorization server.
63    pub fn metadata_url(value: impl AsRef<str>) -> Result<Self, ClientIdError> {
64        let value = value.as_ref();
65        if value.starts_with("https://") {
66            Ok(Self(Arc::from(value)))
67        } else {
68            Err(ClientIdError::NotHttps)
69        }
70    }
71
72    /// The identifier as it goes on the wire.
73    #[must_use]
74    pub fn as_str(&self) -> &str {
75        &self.0
76    }
77
78    /// Check that a metadata document vouches for this `client_id`.
79    ///
80    /// The draft requires the document's `client_id` property to "match the URL
81    /// of the document using simple string comparison as defined in RFC 3986
82    /// Section 6.2.1" — the same discipline
83    /// [`check_response_issuer`](crate::domain::oauth::check_response_issuer)
84    /// applies to RFC 9207's `iss`, and for the same reason. Normalising here
85    /// would let a document served at one URL claim the identity of another.
86    ///
87    /// # Errors
88    ///
89    /// [`ClientIdError::DocumentMismatch`] when the document's `client_id` is
90    /// not this URL by simple string comparison.
91    pub fn verify_metadata_document(&self, document_client_id: &str) -> Result<(), ClientIdError> {
92        if document_client_id == self.0.as_ref() {
93            Ok(())
94        } else {
95            Err(ClientIdError::DocumentMismatch)
96        }
97    }
98}
99
100impl fmt::Debug for ClientId {
101    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
102        formatter.debug_tuple("ClientId").field(&self.0).finish()
103    }
104}
105
106impl fmt::Display for ClientId {
107    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
108        formatter.write_str(&self.0)
109    }
110}
111
112/// A client secret from an RFC 7591 registration response.
113///
114/// # Redaction
115///
116/// Renders as `ClientSecret(…)` under [`Debug`], which is what a panic and an
117/// `assert_eq!` expand to. It authenticates the client to its authorization
118/// server, so it is the credential a confused-deputy leak hands over, and it is
119/// not the sort of value that belongs in a build log because a comparison
120/// failed. [`Display`](fmt::Display) stays full: an RFC 7591 client
121/// authenticates with `client_secret` in a token request body, so the value has
122/// to be serialisable.
123#[derive(Clone, PartialEq, Eq)]
124pub struct ClientSecret(Arc<str>);
125
126impl ClientSecret {
127    /// A secret an authorization server has just issued.
128    ///
129    /// Named for where the value comes from rather than left as `new`, because
130    /// the one legitimate source is a registration response and an empty or
131    /// placeholder string is not one. Nothing validates the contents: the
132    /// server chose them, and inventing a rule here would refuse a legal
133    /// server's identifier. What is enforced is the type — there is no
134    /// `Default`, and `Debug` redacts, so a secret cannot be conjured by
135    /// omission or land in a log by accident.
136    #[must_use]
137    pub fn issued(value: impl AsRef<str>) -> Self {
138        Self(Arc::from(value.as_ref()))
139    }
140
141    /// The secret, as it goes in a `client_secret` form field.
142    #[must_use]
143    pub fn expose(&self) -> &str {
144        &self.0
145    }
146}
147
148impl fmt::Debug for ClientSecret {
149    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
150        formatter.write_str("ClientSecret(…)")
151    }
152}
153
154/// How a client authenticates at the token endpoint (RFC 7591 §2).
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub enum TokenAuthMethod {
157    /// HTTP Basic with the URL-encoded id and secret (RFC 6749 §2.3.1).
158    ClientSecretBasic,
159    /// `client_id` and `client_secret` in the form body.
160    ClientSecretPost,
161    /// A public client: `client_id` in the body, no secret anywhere.
162    None,
163}
164
165impl TokenAuthMethod {
166    /// The RFC 7591 name.
167    #[must_use]
168    pub const fn as_str(self) -> &'static str {
169        match self {
170            Self::ClientSecretBasic => "client_secret_basic",
171            Self::ClientSecretPost => "client_secret_post",
172            Self::None => "none",
173        }
174    }
175
176    /// Reads an RFC 7591 name; anything else is not a method this client speaks.
177    #[must_use]
178    pub fn parse(name: &str) -> Option<Self> {
179        match name {
180            "client_secret_basic" => Some(Self::ClientSecretBasic),
181            "client_secret_post" => Some(Self::ClientSecretPost),
182            "none" => Some(Self::None),
183            _ => None,
184        }
185    }
186}
187
188/// A client as one authorization server knows it.
189///
190/// The issuer is part of the identity rather than a field a caller must remember
191/// to check, which is the whole point of `2026-07-28` §9. There is no accessor
192/// that returns a `ClientSecret` for a given issuer, because asking is how the
193/// mistake gets made.
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct RegisteredClient {
196    issuer: Arc<str>,
197    client_id: ClientId,
198    secret: Option<ClientSecret>,
199    auth_method: TokenAuthMethod,
200}
201
202impl RegisteredClient {
203    /// A client that obtained its identifier through RFC 7591.
204    ///
205    /// # Errors
206    ///
207    /// [`ClientIdError::DocumentRegistration`] if `client_id` is a metadata
208    /// document URL, or [`ClientIdError::SecretForDocument`] if a secret is
209    /// offered for one. A document-issued client has no secret to present.
210    pub fn dynamic(
211        issuer: impl AsRef<str>,
212        client_id: ClientId,
213        secret: Option<ClientSecret>,
214    ) -> Result<Self, ClientIdError> {
215        if client_id.0.starts_with("https://") {
216            return Err(ClientIdError::DocumentRegistration);
217        }
218        // RFC 7591 §2: the default is `client_secret_basic`, which needs a
219        // secret; a client issued none can only be public.
220        let auth_method = if secret.is_some() {
221            TokenAuthMethod::ClientSecretBasic
222        } else {
223            TokenAuthMethod::None
224        };
225        Ok(Self {
226            issuer: Arc::from(issuer.as_ref()),
227            client_id,
228            secret,
229            auth_method,
230        })
231    }
232
233    /// A client identified by a Client ID Metadata Document.
234    ///
235    /// Never carries a secret: the draft is explicit that no shared secret can
236    /// be established for this form, so there is no constructor parameter in
237    /// which to get it wrong.
238    #[must_use]
239    pub fn from_metadata_document(issuer: impl AsRef<str>, client_id: ClientId) -> Self {
240        Self {
241            issuer: Arc::from(issuer.as_ref()),
242            client_id,
243            secret: None,
244            auth_method: TokenAuthMethod::None,
245        }
246    }
247
248    /// This client, authenticating at the token endpoint with `method`.
249    ///
250    /// For the method the registration response named, or the one a
251    /// pre-registration was issued with. A secret-based method on a client
252    /// with no secret is kept as asked; the token endpoint will refuse it,
253    /// which is a clearer failure than silently going public.
254    #[must_use]
255    pub const fn with_auth_method(mut self, method: TokenAuthMethod) -> Self {
256        self.auth_method = method;
257        self
258    }
259
260    /// How this client authenticates at the token endpoint.
261    #[must_use]
262    pub const fn auth_method(&self) -> TokenAuthMethod {
263        self.auth_method
264    }
265
266    /// The issuer whose credential this is.
267    #[must_use]
268    pub fn issuer(&self) -> &str {
269        &self.issuer
270    }
271
272    /// The identifier to present at this issuer.
273    #[must_use]
274    pub const fn client_id(&self) -> &ClientId {
275        &self.client_id
276    }
277
278    /// The secret, if this client has one.
279    ///
280    /// Returns `None` for a metadata-document client, and `Some` only when the
281    /// caller has already established which issuer it is talking to.
282    #[must_use]
283    pub const fn secret(&self) -> Option<&ClientSecret> {
284        self.secret.as_ref()
285    }
286
287    /// Whether this credential was issued by `issuer`.
288    #[must_use]
289    pub fn is_issued_by(&self, issuer: &str) -> bool {
290        self.issuer.as_ref() == issuer
291    }
292}
293
294/// What a client should do with its stored credential when starting a flow.
295#[derive(Debug, Clone, Copy, PartialEq, Eq)]
296pub enum RegistrationDecision {
297    /// Nothing is stored, so register.
298    Register,
299    /// A credential for this issuer is stored, so use it.
300    Reuse,
301    /// Something is stored but it belongs to a different authorization server.
302    ///
303    /// `2026-07-28` §9 (SEP-2352): a client **MUST** re-register when the
304    /// authorization server changes, and **MUST NOT** reuse the old credential
305    /// with the new one. Before that revision the rule is not written down, so
306    /// reuse is permitted — the older models carry no such requirement, and a
307    /// function that ignored the gate would be claiming the specification said
308    /// something it does not.
309    ReRegister,
310}
311
312/// Decide what to do with a stored credential.
313///
314/// Takes the revision's model rather than a bare boolean so the caller cannot
315/// forget which revision it is negotiating, and so the decision is testable
316/// against `authorization_model` rather than against a hand-written list.
317#[must_use]
318pub fn decide_registration(
319    model: &AuthorizationModel,
320    issuer: &str,
321    stored: Option<&RegisteredClient>,
322) -> RegistrationDecision {
323    match stored {
324        None => RegistrationDecision::Register,
325        Some(stored) if stored.is_issued_by(issuer) => RegistrationDecision::Reuse,
326        Some(_) if model.credentials_bound_to_issuer => RegistrationDecision::ReRegister,
327        // No issuer binding required: reusing across servers is permitted,
328        // though the new server will have its own view of whether it accepts it.
329        Some(_) => RegistrationDecision::Reuse,
330    }
331}
332
333/// A `client_id` or metadata document that does not satisfy what the mechanism
334/// requires.
335#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
336pub enum ClientIdError {
337    /// A metadata document `client_id` that is not an HTTPS URL.
338    #[error("a client id metadata document URL must use the https scheme")]
339    NotHttps,
340
341    /// A document whose `client_id` is not the URL it was served from.
342    #[error("the metadata document's client_id does not match the URL it was served from")]
343    DocumentMismatch,
344
345    /// An RFC 7591 registration whose `client_id` is a metadata document URL.
346    #[error("a dynamically registered client_id must not be a metadata document URL")]
347    DocumentRegistration,
348
349    /// A secret offered for a client identified by a metadata document.
350    #[error("a client id metadata document client cannot hold a client secret")]
351    SecretForDocument,
352}
353
354#[cfg(test)]
355mod tests {
356    use std::sync::Arc;
357
358    use super::{
359        ClientId, ClientIdError, ClientSecret, RegisteredClient, RegistrationDecision,
360        decide_registration,
361    };
362    use crate::domain::ProtocolVersion;
363    use crate::domain::mcp::authorization::{AuthorizationModel, authorization_model};
364
365    const AS: &str = "https://auth.example.com";
366    const OTHER_AS: &str = "https://auth.other.example";
367    const DOCUMENT_URL: &str = "https://client.example.com/metadata.json";
368
369    const fn model_for(version: ProtocolVersion) -> AuthorizationModel {
370        authorization_model(version)
371    }
372
373    fn document_url() -> Result<ClientId, ClientIdError> {
374        ClientId::metadata_url(DOCUMENT_URL)
375    }
376
377    fn secret() -> ClientSecret {
378        ClientSecret(Arc::from("s3cret-value"))
379    }
380
381    fn document_client() -> Result<RegisteredClient, ClientIdError> {
382        Ok(RegisteredClient::from_metadata_document(
383            AS,
384            document_url()?,
385        ))
386    }
387
388    /// The secret is optional for a dynamic client — a public client with PKCE
389    /// and no confidential leg is the ordinary case, not a degenerate one.
390    #[test]
391    fn a_dynamic_client_may_or_may_not_hold_a_secret() -> Result<(), ClientIdError> {
392        let confidential =
393            RegisteredClient::dynamic(AS, ClientId::opaque("client-abc"), Some(secret()))?;
394        assert!(confidential.secret().is_some());
395        assert_eq!(confidential.secret(), Some(&secret()));
396
397        let public = RegisteredClient::dynamic(AS, ClientId::opaque("client-xyz"), None)?;
398        assert!(public.secret().is_none());
399        Ok(())
400    }
401
402    /// The draft is explicit that no shared secret can be established for this
403    /// form, so the document-issued client cannot be given one at all.
404    #[test]
405    fn a_metadata_document_client_has_no_secret() -> Result<(), ClientIdError> {
406        assert!(document_client()?.secret().is_none());
407        Ok(())
408    }
409
410    /// A document URL offered as an RFC 7591 `client_id` means the two
411    /// mechanisms have been confused.
412    #[test]
413    fn a_document_url_cannot_be_a_dynamic_registration() -> Result<(), ClientIdError> {
414        assert_eq!(
415            RegisteredClient::dynamic(AS, document_url()?, None),
416            Err(ClientIdError::DocumentRegistration)
417        );
418        Ok(())
419    }
420
421    #[test]
422    fn a_metadata_client_id_must_be_https() {
423        for value in [
424            "http://client.example.com/metadata.json",
425            "ftp://client.example.com/metadata.json",
426            "client.example.com/metadata.json",
427        ] {
428            assert_eq!(
429                ClientId::metadata_url(value),
430                Err(ClientIdError::NotHttps),
431                "`{value}` is not an https URL"
432            );
433        }
434    }
435
436    /// The draft requires simple string comparison, citing RFC 3986 §6.2.1 —
437    /// the same rule RFC 9207's `iss` follows.
438    #[test]
439    fn a_document_must_vouch_for_its_own_url() -> Result<(), ClientIdError> {
440        let document = document_url()?;
441        assert_eq!(document.verify_metadata_document(DOCUMENT_URL), Ok(()));
442        for claimed in [
443            "https://client.example.com/metadata.json/",
444            "https://CLIENT.example.com/metadata.json",
445            "https://client.example.com/metadata.json#",
446            "https://client.example.com/other.json",
447            "https://evil.example.com/metadata.json",
448        ] {
449            assert_eq!(
450                document.verify_metadata_document(claimed),
451                Err(ClientIdError::DocumentMismatch),
452                "`{claimed}` must not verify as the document URL"
453            );
454        }
455        Ok(())
456    }
457
458    /// `Debug` is where a secret escapes by accident; `expose` is where it escapes
459    /// on purpose. Both halves are pinned so neither can drift into the other.
460    #[test]
461    fn a_secret_is_redacted_in_debug_and_exposed_only_on_request() {
462        let secret = secret();
463        let rendered = format!("{secret:?}");
464        assert_eq!(rendered, "ClientSecret(…)");
465        assert!(
466            !rendered.contains("s3cret"),
467            "the client secret leaked: {rendered}"
468        );
469        assert_eq!(secret.expose(), "s3cret-value");
470    }
471
472    #[test]
473    fn no_stored_credential_means_register() {
474        for version in [ProtocolVersion::V2024_11_05, ProtocolVersion::V2026_07_28] {
475            assert_eq!(
476                decide_registration(&model_for(version), AS, None),
477                RegistrationDecision::Register
478            );
479        }
480    }
481
482    #[test]
483    fn a_credential_for_this_issuer_is_reused() -> Result<(), ClientIdError> {
484        let stored = document_client()?;
485        for version in [ProtocolVersion::V2025_06_18, ProtocolVersion::V2026_07_28] {
486            assert_eq!(
487                decide_registration(&model_for(version), AS, Some(&stored)),
488                RegistrationDecision::Reuse
489            );
490        }
491        Ok(())
492    }
493
494    /// The rule this module exists for. SEP-2352: a changed authorization
495    /// server means re-register, never reuse.
496    #[test]
497    fn a_credential_from_another_issuer_is_never_reused_in_2026_07_28() -> Result<(), ClientIdError>
498    {
499        let stored = document_client()?;
500        let model = model_for(ProtocolVersion::V2026_07_28);
501        assert!(model.credentials_bound_to_issuer);
502        assert_eq!(
503            decide_registration(&model, OTHER_AS, Some(&stored)),
504            RegistrationDecision::ReRegister
505        );
506        Ok(())
507    }
508
509    /// Before the requirement existed, reuse across servers was permitted. The
510    /// gate is real, so the older rows must actually behave differently rather
511    /// than the function quietly ignoring it.
512    #[test]
513    fn an_earlier_revision_permits_cross_issuer_reuse() -> Result<(), ClientIdError> {
514        let stored = document_client()?;
515        for version in [
516            ProtocolVersion::V2025_03_26,
517            ProtocolVersion::V2025_06_18,
518            ProtocolVersion::V2025_11_25,
519        ] {
520            let model = model_for(version);
521            assert!(
522                !model.credentials_bound_to_issuer,
523                "{version:?} must not bind credentials to the issuer"
524            );
525            assert_eq!(
526                decide_registration(&model, OTHER_AS, Some(&stored)),
527                RegistrationDecision::Reuse,
528                "{version:?} does not require re-registration"
529            );
530        }
531        Ok(())
532    }
533
534    /// The issuer is part of the identity, so a credential cannot be matched by
535    /// its `client_id` alone.
536    #[test]
537    fn a_credential_names_the_issuer_that_issued_it() -> Result<(), ClientIdError> {
538        let stored = document_client()?;
539        assert!(stored.is_issued_by(AS));
540        assert!(!stored.is_issued_by(OTHER_AS));
541        assert_eq!(stored.issuer(), AS);
542        assert_eq!(stored.client_id().as_str(), DOCUMENT_URL);
543        Ok(())
544    }
545}