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}