Skip to main content

protocol_droid/application/oauth/
client.rs

1//! Beginning an OAuth authorization flow: what the client sends the user to,
2//! and what it must keep until the user comes back.
3//!
4//! # The half that happens in a browser
5//!
6//! `GET` the authorization endpoint, the user authenticates there, and the
7//! authorization server redirects to the client's registered redirect URI with
8//! a `code` and a `state`. Between those two points the client holds two
9//! secrets it generated and must not lose: the `state` that proves this
10//! callback belongs to this request, and the PKCE `code_verifier` that the
11//! `code_challenge` was derived from.
12//!
13//! Both are redacted under [`Debug`](core::fmt::Debug) on
14//! [`PendingAuthorization`], because that value is the one a client will hold
15//! in memory for the whole user-visible round trip, and it is exactly the value
16//! a panic message, an `assert_eq!` or a future log line would otherwise render.
17//!
18//! # What this deliberately does not do
19//!
20//! It does not redeem the code, and it does not talk to the authorization
21//! server. [`PendingAuthorization`] is the value that survives the redirect; a
22//! separate use case consumes it with the callback and owns the token request.
23//! Splitting them is what lets the same pending value be tested against a
24//! recorded callback without a socket.
25
26use core::fmt;
27use std::sync::Arc;
28
29use url::Url;
30
31use crate::domain::mcp::authorization::AuthorizationModel;
32use crate::domain::oauth::pkce::{ChallengeMethod, CodeChallenge, CodeVerifier};
33use crate::domain::oauth::registration::{ClientId, RegisteredClient};
34use crate::domain::oauth::resource::{ResourceBinding, ResourceIndicatorError, ResourceParameters};
35
36/// A source of the unguessable values an authorization request is built from.
37///
38/// Narrow, and owned by this use case because this use case is what consumes
39/// it. An adapter implements it; the crate ships none by default, because the
40/// right source depends on where the client runs and a wrong default is a
41/// predictable token rather than a compile error.
42///
43/// Both methods are fallible: the usual implementation draws from the OS
44/// entropy source, which is a runtime facility that can fail, and a client that
45/// quietly falls back to something guessable is the failure mode this avoids.
46pub trait AuthorizationSecrets {
47    /// A fresh `state` for one authorization request.
48    ///
49    /// # Errors
50    ///
51    /// Whatever the underlying source raises when it cannot produce one.
52    fn new_state(&self) -> Result<State, SecretError>;
53
54    /// A fresh PKCE `code_verifier` for one authorization request.
55    ///
56    /// # Errors
57    ///
58    /// Whatever the underlying source raises when it cannot produce one.
59    fn new_code_verifier(&self) -> Result<CodeVerifier, SecretError>;
60}
61
62/// A source of unguessable values could not produce one.
63///
64/// The variants exist to separate the two failures a caller has to tell apart.
65/// A source that cannot reach the operating system's entropy is an environment
66/// problem, fixed by restarting or by moving somewhere the source exists;
67/// [`Rejected`](Self::Rejected) means this crate's own encoder produced a
68/// value its own value object refused, which is an internal invariant and a bug
69/// here. One unit error for both would make the second indistinguishable from
70/// the first, and the second is the one worth a bug report.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
72pub enum SecretError {
73    /// The operating system's entropy source could not be read.
74    #[error("the operating system's entropy source could not be read")]
75    EntropyUnavailable,
76
77    /// This platform provides no usable entropy source.
78    #[error("this platform provides no usable entropy source")]
79    EntropyUnsupported,
80
81    /// The value drawn was refused by the value object that owns it.
82    ///
83    /// An adapter drawing 32 bytes and encoding them base64url produces 43
84    /// characters drawn from a subset of the unreserved set, so it cannot fail
85    /// the length or charset rule it is checked against. Reaching this variant
86    /// means the encoding and that rule have diverged.
87    #[error("the value drawn was refused by the value object that owns it")]
88    Rejected,
89}
90
91/// An OAuth `state` value: the nonce tying one authorization response to one
92/// request.
93///
94/// # Redaction
95///
96/// Renders as `State(…)`. A `state` is a CSRF token — an attacker who learns
97/// one can replay the callback it belongs to — so it does not belong in a log
98/// line, and [`Debug`](fmt::Debug) is the boundary that keeps it out.
99#[derive(Clone, PartialEq, Eq)]
100pub struct State(Arc<str>);
101
102impl State {
103    /// Wrap a value the client generated elsewhere.
104    #[must_use]
105    pub fn new(value: impl AsRef<str>) -> Self {
106        Self(Arc::from(value.as_ref()))
107    }
108
109    /// The value as it travels in the query string.
110    #[must_use]
111    pub fn as_str(&self) -> &str {
112        &self.0
113    }
114
115    /// Whether this is the `state` the request expected.
116    ///
117    /// Plain equality, and deliberately not a constant-time comparison: `state`
118    /// is compared against a value the caller already holds, it is not a
119    /// bearer secret being presented to a verifier that would otherwise leak
120    /// its contents through timing. The PKCE verifier is the one compared
121    /// against an attacker-supplied value, and that comparison happens at the
122    /// authorization server, not here.
123    #[must_use]
124    pub fn matches(&self, presented: &str) -> bool {
125        self.0.as_ref() == presented
126    }
127}
128
129impl fmt::Debug for State {
130    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
131        formatter.write_str("State(…)")
132    }
133}
134
135impl fmt::Display for State {
136    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
137        formatter.write_str(&self.0)
138    }
139}
140
141/// Everything a client must keep between sending the user away and handling the
142/// callback.
143///
144/// # Redaction
145///
146/// [`Debug`] renders the secrets as `…` and the rest normally. The verifier and
147/// the `state` are both load-bearing — one redeems the code, the other proves
148/// the callback belongs to this request — and this is the value a client holds
149/// in memory across a user-visible redirect.
150#[derive(Clone)]
151pub struct PendingAuthorization {
152    state: State,
153    verifier: CodeVerifier,
154    resource: ResourceBinding,
155    client_id: ClientId,
156    redirect_uri: Arc<str>,
157    issuer: Arc<str>,
158    /// The `scope` to request, already chosen by the caller.
159    scope: Option<Arc<str>>,
160    /// Whether the server's metadata advertised
161    /// `authorization_response_iss_parameter_supported`.
162    iss_advertised: bool,
163}
164
165impl PendingAuthorization {
166    /// The `state` this request issued, for comparing against the callback.
167    #[must_use]
168    pub const fn state(&self) -> &State {
169        &self.state
170    }
171
172    /// The PKCE verifier to present when redeeming the code.
173    #[must_use]
174    pub const fn code_verifier(&self) -> &CodeVerifier {
175        &self.verifier
176    }
177
178    /// What the grant is bound to, and what the token request must repeat.
179    #[must_use]
180    pub const fn resource(&self) -> &ResourceBinding {
181        &self.resource
182    }
183
184    /// The redirect URI the authorization request named.
185    #[must_use]
186    pub fn redirect_uri(&self) -> &str {
187        &self.redirect_uri
188    }
189
190    /// The client this request was made as.
191    ///
192    /// The token request has to name the same client, so a caller resuming the
193    /// flow after the redirect needs it back from here rather than keeping a
194    /// second copy alongside the pending value.
195    #[must_use]
196    pub const fn client_id(&self) -> &ClientId {
197        &self.client_id
198    }
199
200    /// The issuer this flow is with.
201    ///
202    /// Carried so the redemption step can check an `iss` response against
203    /// something the client recorded when it *started*, rather than against a
204    /// value read from the response it is trying to validate.
205    #[must_use]
206    pub fn issuer(&self) -> &str {
207        &self.issuer
208    }
209
210    /// This request, asking for `scope` (space-separated).
211    ///
212    /// Omitted when not set: the authorization page has the client omit
213    /// `scope` entirely when neither the challenge nor `scopes_supported`
214    /// names any.
215    #[must_use]
216    pub fn with_scope(mut self, scope: impl AsRef<str>) -> Self {
217        self.scope = Some(Arc::from(scope.as_ref()));
218        self
219    }
220
221    /// The scope this request asks for, if any.
222    #[must_use]
223    pub fn scope(&self) -> Option<&str> {
224        self.scope.as_deref()
225    }
226
227    /// This request, recording that the server advertised `iss` support, so a
228    /// response without one is refused.
229    #[must_use]
230    pub const fn with_iss_advertised(mut self, advertised: bool) -> Self {
231        self.iss_advertised = advertised;
232        self
233    }
234
235    /// Whether the server advertised `iss` support.
236    #[must_use]
237    pub const fn iss_advertised(&self) -> bool {
238        self.iss_advertised
239    }
240
241    /// Render the URL to send the user to.
242    ///
243    /// Appends to whatever query the endpoint already carries, because the
244    /// endpoint came from a discovery document this crate does not control.
245    ///
246    /// # Errors
247    ///
248    /// [`BeginError::Endpoint`] when the authorization endpoint is not a URL
249    /// this crate can append to.
250    pub fn authorization_url(
251        &self,
252        endpoint: &str,
253        method: ChallengeMethod,
254    ) -> Result<Url, BeginError> {
255        let mut url = Url::parse(endpoint).map_err(|source| BeginError::Endpoint {
256            endpoint: endpoint.to_owned(),
257            source,
258        })?;
259        let challenge: CodeChallenge = self.verifier.challenge(method);
260        {
261            let mut query = url.query_pairs_mut();
262            query.append_pair("response_type", "code");
263            query.append_pair("client_id", self.client_id.as_str());
264            query.append_pair("redirect_uri", &self.redirect_uri);
265            query.append_pair("state", self.state.as_str());
266            query.append_pair("code_challenge", challenge.as_str());
267            query.append_pair("code_challenge_method", method.as_str());
268            for resource in self.resource.token_request().iter() {
269                query.append_pair("resource", resource);
270            }
271            if let Some(scope) = &self.scope {
272                query.append_pair("scope", scope);
273            }
274        }
275        Ok(url)
276    }
277}
278
279impl fmt::Debug for PendingAuthorization {
280    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
281        formatter
282            .debug_struct("PendingAuthorization")
283            .field("state", &self.state)
284            .field("code_verifier", &self.verifier)
285            .field("resource", &self.resource)
286            .field("client_id", &self.client_id)
287            .field("redirect_uri", &self.redirect_uri)
288            .field("issuer", &self.issuer)
289            .field("scope", &self.scope)
290            .field("iss_advertised", &self.iss_advertised)
291            .finish()
292    }
293}
294
295/// Why an authorization request could not be started.
296#[derive(Debug, thiserror::Error)]
297pub enum BeginError {
298    /// The secret source could not produce a `state` or a verifier.
299    #[error("the authorization secret source failed")]
300    Secrets(#[source] SecretError),
301
302    /// The revision requires a resource indicator and none was named.
303    #[error(transparent)]
304    Resource(#[from] ResourceIndicatorError),
305
306    /// The authorization endpoint is not a URL this crate can append to.
307    #[error("the authorization endpoint `{endpoint}` is not a usable URL: {source}")]
308    Endpoint {
309        /// The endpoint as configured or discovered.
310        endpoint: String,
311        /// Why it was refused.
312        #[source]
313        source: url::ParseError,
314    },
315}
316
317/// Build the authorization request and the state a client must keep for it.
318///
319/// # Errors
320///
321/// [`BeginError::Secrets`] if the source cannot produce both unguessable
322/// values, [`BeginError::Resource`] when the revision requires a resource
323/// indicator and the caller named none, and [`BeginError::Endpoint`] when the
324/// endpoint will not parse.
325///
326/// The result is deliberately a pair — the URL and the pending value — because
327/// the two are easy to take independently and must not be: a client that
328/// renders the URL and then persists a *different* pending value has a `state`
329/// mismatch on return and no way to tell whether that is an attack or a bug.
330///
331/// # Examples
332///
333/// ```
334/// use protocol_droid::domain::ProtocolVersion;
335/// use protocol_droid::domain::mcp::authorization::authorization_model;
336/// use protocol_droid::domain::mcp::resource_metadata::ResourceUri;
337/// use protocol_droid::domain::oauth::registration::{ClientId, RegisteredClient};
338/// use protocol_droid::domain::oauth::resource::ResourceParameters;
339/// use protocol_droid::application::oauth::client::{AuthorizationSecrets, SecretError};
340/// use protocol_droid::application::oauth::client::begin_authorization;
341/// use protocol_droid::domain::oauth::pkce::{ChallengeMethod, CodeVerifier};
342///
343/// struct Fixed;
344/// impl AuthorizationSecrets for Fixed {
345///     fn new_state(&self) -> Result<protocol_droid::application::oauth::client::State, SecretError> {
346///         Ok(protocol_droid::application::oauth::client::State::new("state-value"))
347///     }
348///     fn new_code_verifier(&self) -> Result<CodeVerifier, SecretError> {
349///         CodeVerifier::new("a".repeat(43)).map_err(|_| SecretError::Rejected)
350///     }
351/// }
352///
353/// let model = authorization_model(ProtocolVersion::V2026_07_28);
354/// let client = RegisteredClient::from_metadata_document(
355///     "https://auth.example.com",
356///     ClientId::metadata_url("https://client.example.com/metadata.json")?,
357/// );
358/// let resource = ResourceUri::new("https://api.example.com/app")?;
359/// let pending = begin_authorization(
360///     &model,
361///     &client,
362///     "https://client.example.com/callback",
363///     ResourceParameters::single(&resource),
364///     &Fixed,
365/// )?;
366///
367/// let url = pending.authorization_url("https://auth.example.com/authorize", ChallengeMethod::S256)?;
368/// assert!(url.as_str().contains("code_challenge_method=S256"));
369/// assert!(url.as_str().contains("resource=https%3A%2F%2Fapi.example.com%2Fapp"));
370/// # Ok::<(), Box<dyn std::error::Error>>(())
371/// ```
372pub fn begin_authorization(
373    model: &AuthorizationModel,
374    client: &RegisteredClient,
375    redirect_uri: &str,
376    resource: ResourceParameters,
377    secrets: &impl AuthorizationSecrets,
378) -> Result<PendingAuthorization, BeginError> {
379    let state = secrets.new_state().map_err(BeginError::Secrets)?;
380    let verifier = secrets.new_code_verifier().map_err(BeginError::Secrets)?;
381    // Built after the secrets rather than before: an empty resource set is a
382    // caller mistake the model can reject, and there is no reason to have drawn
383    // randomness for a request that will not be sent.
384    let resource = ResourceBinding::bind(model, resource)?;
385    Ok(PendingAuthorization {
386        state,
387        verifier,
388        resource,
389        client_id: client.client_id().clone(),
390        redirect_uri: Arc::from(redirect_uri),
391        issuer: Arc::from(client.issuer()),
392        scope: None,
393        iss_advertised: false,
394    })
395}
396
397#[cfg(test)]
398mod tests {
399    use std::assert_matches;
400
401    use super::{
402        AuthorizationSecrets, PendingAuthorization, SecretError, State, begin_authorization,
403    };
404    use crate::domain::ProtocolVersion;
405    use crate::domain::mcp::authorization::{AuthorizationModel, authorization_model};
406    use crate::domain::mcp::resource_metadata::{ResourceUri, ResourceUriError};
407    use crate::domain::oauth::pkce::{ChallengeMethod, CodeVerifier, PkceError};
408    use crate::domain::oauth::registration::{ClientId, ClientIdError, RegisteredClient};
409    use crate::domain::oauth::resource::{ResourceIndicatorError, ResourceParameters};
410
411    const AS: &str = "https://auth.example.com";
412    const CLIENT_ID: &str = "https://client.example.com/metadata.json";
413    const REDIRECT: &str = "https://client.example.com/callback";
414    const ENDPOINT: &str = "https://auth.example.com/authorize";
415    const VERIFIER: &str = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
416    const STATE: &str = "a-state-value";
417
418    /// Every fallible call in this module, in one type, so a test can `?`
419    /// through the lot instead of stopping at whichever error comes first.
420    #[derive(Debug)]
421    enum Failure {
422        Uri(ResourceUriError),
423        ClientId(ClientIdError),
424        Pkce(PkceError),
425        Resource(ResourceIndicatorError),
426        Begin(super::BeginError),
427    }
428
429    impl From<ResourceUriError> for Failure {
430        fn from(error: ResourceUriError) -> Self {
431            Self::Uri(error)
432        }
433    }
434    impl From<ClientIdError> for Failure {
435        fn from(error: ClientIdError) -> Self {
436            Self::ClientId(error)
437        }
438    }
439    impl From<PkceError> for Failure {
440        fn from(error: PkceError) -> Self {
441            Self::Pkce(error)
442        }
443    }
444    impl From<ResourceIndicatorError> for Failure {
445        fn from(error: ResourceIndicatorError) -> Self {
446            Self::Resource(error)
447        }
448    }
449    impl From<super::BeginError> for Failure {
450        fn from(error: super::BeginError) -> Self {
451            Self::Begin(error)
452        }
453    }
454
455    /// Delegates so a failing test prints the engine's own account of what it
456    /// refused. It also reads the fields, which is why they are not dead: a
457    /// derived `Debug` does not count as a read, so without this every variant
458    /// trips `dead_code`.
459    impl core::fmt::Display for Failure {
460        fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
461            match self {
462                Self::Uri(error) => write!(formatter, "{error}"),
463                Self::ClientId(error) => write!(formatter, "{error}"),
464                Self::Pkce(error) => write!(formatter, "{error}"),
465                Self::Resource(error) => write!(formatter, "{error}"),
466                Self::Begin(error) => write!(formatter, "{error}"),
467            }
468        }
469    }
470
471    /// A source with fixed output, so the assertions are about the request this
472    /// module builds and not about whatever a real entropy source produced.
473    struct Fixed;
474
475    impl AuthorizationSecrets for Fixed {
476        fn new_state(&self) -> Result<State, SecretError> {
477            Ok(State::new(STATE))
478        }
479        fn new_code_verifier(&self) -> Result<CodeVerifier, SecretError> {
480            CodeVerifier::new(VERIFIER).map_err(|_| SecretError::Rejected)
481        }
482    }
483
484    /// A source that cannot produce anything, which is the branch that matters:
485    /// a client that carried on regardless would be sending a guessable state.
486    struct Unavailable;
487
488    impl AuthorizationSecrets for Unavailable {
489        fn new_state(&self) -> Result<State, SecretError> {
490            Err(SecretError::EntropyUnavailable)
491        }
492        fn new_code_verifier(&self) -> Result<CodeVerifier, SecretError> {
493            Err(SecretError::EntropyUnavailable)
494        }
495    }
496
497    fn model_for(version: ProtocolVersion) -> AuthorizationModel {
498        authorization_model(version)
499    }
500
501    fn client() -> Result<RegisteredClient, ClientIdError> {
502        Ok(RegisteredClient::from_metadata_document(
503            AS,
504            ClientId::metadata_url(CLIENT_ID)?,
505        ))
506    }
507
508    fn resource() -> Result<ResourceParameters, ResourceUriError> {
509        Ok(ResourceParameters::single(&ResourceUri::new(
510            "https://api.example.com/app",
511        )?))
512    }
513
514    fn pending(version: ProtocolVersion) -> Result<PendingAuthorization, Failure> {
515        Ok(begin_authorization(
516            &model_for(version),
517            &client()?,
518            REDIRECT,
519            resource()?,
520            &Fixed,
521        )?)
522    }
523
524    /// RFC 7636 Appendix A: the S256 derivation is checked against the wire
525    /// form here too, so a change to the query rendering cannot quietly pass a
526    /// challenge the server will reject.
527    #[test]
528    fn the_request_carries_the_challenge_not_the_verifier() -> Result<(), Failure> {
529        let pending = pending(ProtocolVersion::V2026_07_28)?;
530        let url = pending.authorization_url(ENDPOINT, ChallengeMethod::S256)?;
531        let query = url.query().unwrap_or_default();
532
533        assert!(
534            query.contains("code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"),
535            "{query}"
536        );
537        assert!(query.contains("code_challenge_method=S256"), "{query}");
538        assert!(
539            !query.contains(VERIFIER),
540            "the verifier must not appear in the redirect: {query}"
541        );
542        Ok(())
543    }
544
545    #[test]
546    fn the_request_carries_the_four_parameters_a_redirect_needs() -> Result<(), Failure> {
547        let pending = pending(ProtocolVersion::V2026_07_28)?;
548        let url = pending.authorization_url(ENDPOINT, ChallengeMethod::S256)?;
549        let query = url.query().unwrap_or_default();
550
551        assert!(query.contains("response_type=code"), "{query}");
552        assert!(
553            query.contains(&format!(
554                "client_id={}",
555                CLIENT_ID.replace(':', "%3A").replace('/', "%2F")
556            )),
557            "{query}"
558        );
559        assert!(
560            query.contains(&format!(
561                "redirect_uri={}",
562                REDIRECT.replace(':', "%3A").replace('/', "%2F")
563            )),
564            "{query}"
565        );
566        assert!(query.contains(&format!("state={STATE}")), "{query}");
567        Ok(())
568    }
569
570    /// The endpoint comes from a discovery document this crate does not
571    /// control, so it may already carry a query. Appending must not discard it.
572    #[test]
573    fn an_endpoint_that_already_has_a_query_keeps_it() -> Result<(), Failure> {
574        let pending = pending(ProtocolVersion::V2026_07_28)?;
575        let url = pending.authorization_url(
576            "https://auth.example.com/authorize?tenant=acme",
577            ChallengeMethod::S256,
578        )?;
579        let query = url.query().unwrap_or_default();
580        assert!(
581            query.contains("tenant=acme"),
582            "an existing query was dropped: {query}"
583        );
584        assert!(query.contains("response_type=code"), "{query}");
585        Ok(())
586    }
587
588    /// A resource indicator is required from `2025-06-18`, and the whole point
589    /// is that it reaches the wire.
590    #[test]
591    fn the_resource_indicator_reaches_the_wire() -> Result<(), Failure> {
592        let pending = pending(ProtocolVersion::V2026_07_28)?;
593        let url = pending.authorization_url(ENDPOINT, ChallengeMethod::S256)?;
594        let query = url.query().unwrap_or_default();
595        assert!(
596            query.contains("resource=https%3A%2F%2Fapi.example.com%2Fapp"),
597            "{query}"
598        );
599        Ok(())
600    }
601
602    /// The whole request is refused rather than sent without one.
603    #[test]
604    fn a_revision_requiring_indicators_refuses_a_request_naming_none() -> Result<(), Failure> {
605        let outcome = begin_authorization(
606            &model_for(ProtocolVersion::V2026_07_28),
607            &client()?,
608            REDIRECT,
609            ResourceParameters::default(),
610            &Fixed,
611        );
612        // `assert_matches!` rather than `assert!(matches!(..))`, because the latter
613        // prints nothing about what it actually saw.
614        assert_matches!(
615            outcome.err(),
616            Some(super::BeginError::Resource(ResourceIndicatorError::Missing))
617        );
618        Ok(())
619    }
620
621    /// A source that cannot produce unguessable values must stop the flow. A
622    /// client that carried on with something guessable would defeat both PKCE
623    /// and the CSRF binding in one step.
624    #[test]
625    fn a_failing_secret_source_stops_the_request() -> Result<(), Failure> {
626        let outcome = begin_authorization(
627            &model_for(ProtocolVersion::V2026_07_28),
628            &client()?,
629            REDIRECT,
630            resource()?,
631            &Unavailable,
632        );
633        assert_matches!(
634            outcome.err(),
635            Some(super::BeginError::Secrets(SecretError::EntropyUnavailable))
636        );
637        Ok(())
638    }
639
640    /// The pending value is the one a client holds across a user-visible round
641    /// trip, and it carries both secrets.
642    #[test]
643    fn debug_redacts_both_secrets_and_keeps_the_rest() -> Result<(), Failure> {
644        let pending = pending(ProtocolVersion::V2026_07_28)?;
645        let rendered = format!("{pending:?}");
646
647        assert!(
648            !rendered.contains(VERIFIER),
649            "the verifier leaked: {rendered}"
650        );
651        assert!(!rendered.contains(STATE), "the state leaked: {rendered}");
652        assert!(
653            rendered.contains(AS),
654            "the issuer should be readable: {rendered}"
655        );
656        assert!(
657            rendered.contains(REDIRECT),
658            "the redirect should be readable: {rendered}"
659        );
660        Ok(())
661    }
662
663    #[test]
664    fn state_matches_only_the_value_this_request_issued() -> Result<(), Failure> {
665        let pending = pending(ProtocolVersion::V2026_07_28)?;
666        assert!(pending.state().matches(STATE));
667        assert!(!pending.state().matches("a-different-state"));
668        assert!(!pending.state().matches(""));
669        Ok(())
670    }
671
672    /// The redemption step needs these to validate the callback against what
673    /// was recorded at the start, not against the response being validated.
674    #[test]
675    fn the_pending_value_carries_what_redemption_needs() -> Result<(), Failure> {
676        let pending = pending(ProtocolVersion::V2026_07_28)?;
677        assert_eq!(pending.issuer(), AS);
678        assert_eq!(pending.redirect_uri(), REDIRECT);
679        assert_eq!(pending.code_verifier().as_str(), VERIFIER);
680        assert_eq!(pending.resource().token_request().len(), 1);
681        Ok(())
682    }
683
684    /// A source that hands out a different value each time, so the difference
685    /// between two requests is observable.
686    struct Sequence(std::cell::Cell<u32>);
687
688    impl AuthorizationSecrets for Sequence {
689        fn new_state(&self) -> Result<State, SecretError> {
690            let next = self.0.get() + 1;
691            self.0.set(next);
692            Ok(State::new(format!("state-{next}")))
693        }
694
695        fn new_code_verifier(&self) -> Result<CodeVerifier, SecretError> {
696            let next = self.0.get() + 1;
697            self.0.set(next);
698            CodeVerifier::new(format!("{next}{}", "a".repeat(42)))
699                .map_err(|_| SecretError::Rejected)
700        }
701    }
702
703    /// A callback from one request must not satisfy another's `state` check,
704    /// so every request draws its own. `Fixed` cannot show this — it returns the
705    /// same value every time — which is why this uses a counting source.
706    #[test]
707    fn each_request_draws_its_own_state() -> Result<(), Failure> {
708        let secrets = Sequence(std::cell::Cell::new(0));
709        let first = begin_authorization(
710            &model_for(ProtocolVersion::V2026_07_28),
711            &client()?,
712            REDIRECT,
713            resource()?,
714            &secrets,
715        )?;
716        let second = begin_authorization(
717            &model_for(ProtocolVersion::V2026_07_28),
718            &client()?,
719            REDIRECT,
720            resource()?,
721            &secrets,
722        )?;
723
724        assert_ne!(first.state(), second.state());
725        assert!(
726            !first.state().matches(second.state().as_str()),
727            "one request's state must not satisfy another's check"
728        );
729        assert_ne!(first.code_verifier(), second.code_verifier());
730        Ok(())
731    }
732
733    #[test]
734    fn an_unusable_endpoint_is_named_rather_than_rendered() -> Result<(), Failure> {
735        let pending = pending(ProtocolVersion::V2026_07_28)?;
736        match pending.authorization_url("not a url", ChallengeMethod::S256) {
737            Err(super::BeginError::Endpoint { endpoint, .. }) => {
738                assert_eq!(endpoint, "not a url");
739                Ok(())
740            }
741            Ok(_) | Err(_) => Err(Failure::Begin(super::BeginError::Secrets(
742                SecretError::EntropyUnavailable,
743            ))),
744        }
745    }
746}