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}