protocol_droid/application/oauth/redeem.rs
1//! Completing the authorization-code exchange: trading the code for a token.
2//!
3//! [`begin_authorization`](super::client::begin_authorization) sends the user
4//! somewhere, and [`authorize_callback`](super::callback::authorize_callback)
5//! validates what comes back. Neither of those produces a token, and this is
6//! the step that does.
7//!
8//! # The request is assembled, never supplied
9//!
10//! Every parameter of the token request is read off the
11//! [`AuthorizedCode`] — the value the callback step produced — rather than
12//! passed in by the caller. That is not tidiness. RFC 6749 §4.1.3 requires the
13//! `redirect_uri` to be **identical** to the one in the authorization request,
14//! and a caller that supplies it separately can supply a different one. The
15//! same goes for the `code_verifier`: presenting anything other than the value
16//! the `code_challenge` was derived from is a failed PKCE check, and the
17//! failure is the server refusing a code the client genuinely owns.
18//!
19//! # The code is consumed, so it cannot be spent twice
20//!
21//! [`redeem`] takes the [`AuthorizedCode`] by value, and `AuthorizedCode` is
22//! not [`Clone`], so one value cannot be redeemed twice. A retry after a
23//! dropped connection — the case where the first attempt may already have been
24//! honoured — therefore cannot silently spend the same code again.
25//!
26//! That is the whole of the guarantee, and it is narrower than "a code is used
27//! once". A caller that runs [`authorize_callback`](crate::application::oauth::callback::authorize_callback) twice against the same
28//! [`PendingAuthorization`](super::client::PendingAuthorization) holds two
29//! values and may try both; RFC 6749 §4.1.2 requires the authorization server
30//! to deny the second. The type closes the accident, the server closes the
31//! choice.
32
33use std::fmt;
34
35use crate::application::oauth::callback::AuthorizedCode;
36use crate::domain::oauth::issued_token::{IssuedToken, TokenResponseError};
37use crate::domain::oauth::pkce::CodeVerifier;
38use crate::domain::oauth::registration::{
39 ClientId, ClientSecret, RegisteredClient, TokenAuthMethod,
40};
41use crate::domain::oauth::resource::ResourceParameters;
42
43/// One token request, assembled from an [`AuthorizedCode`] and a registration.
44///
45/// Borrowed rather than owned because every member already exists in one of
46/// those two values and copying it would create a second copy of a credential
47/// to keep alive for no reason.
48///
49/// # Examples
50///
51/// An implementor reads what it needs and encodes it however its transport
52/// requires; the `oauth` adapter reads all of it:
53///
54/// ```
55/// # use protocol_droid::application::oauth::redeem::{TokenEndpoint, TokenEndpointError, TokenRequest};
56/// # use protocol_droid::domain::oauth::issued_token::{IssuedToken, TokenResponseError};
57/// struct Transport;
58///
59/// impl TokenEndpoint for Transport {
60/// async fn redeem(
61/// &self,
62/// request: TokenRequest<'_>,
63/// ) -> Result<IssuedToken, TokenEndpointError> {
64/// let body = format!(
65/// "grant_type=authorization_code&code={}&redirect_uri={}",
66/// request.code(),
67/// request.redirect_uri(),
68/// );
69/// println!("{body}");
70/// # Ok::<IssuedToken, TokenResponseError>(
71/// # IssuedToken::parse(br#"{"access_token":"at","token_type":"Bearer"}"#)?
72/// # )
73/// .map_err(TokenEndpointError::Refused)
74/// }
75/// }
76/// ```
77#[derive(Debug)]
78pub struct TokenRequest<'a> {
79 code: &'a str,
80 redirect_uri: &'a str,
81 verifier: &'a CodeVerifier,
82 client_id: &'a ClientId,
83 client_secret: Option<&'a ClientSecret>,
84 resource: &'a ResourceParameters,
85 auth_method: TokenAuthMethod,
86 scope: Option<&'a str>,
87}
88
89impl<'a> TokenRequest<'a> {
90 /// The authorization code to redeem.
91 #[must_use]
92 pub const fn code(&self) -> &'a str {
93 self.code
94 }
95
96 /// The redirect URI, exactly as the authorization request carried it.
97 #[must_use]
98 pub const fn redirect_uri(&self) -> &'a str {
99 self.redirect_uri
100 }
101
102 /// The PKCE verifier the `code_challenge` was derived from.
103 #[must_use]
104 pub const fn code_verifier(&self) -> &'a CodeVerifier {
105 self.verifier
106 }
107
108 /// The client this exchange is being conducted as.
109 #[must_use]
110 pub const fn client_id(&self) -> &'a ClientId {
111 self.client_id
112 }
113
114 /// The secret, for a confidential client only.
115 ///
116 /// [`None`] for a public client — one registered through a client
117 /// identity metadata document, which has nowhere to keep a secret that is
118 /// still secret. An adapter must **omit** the member in that case rather
119 /// than sending it empty: an empty `client_secret` is a value, and RFC 6749
120 /// §2.3.1 treats the presence of the member as the statement that the
121 /// client is confidential.
122 #[must_use]
123 pub const fn client_secret(&self) -> Option<&'a ClientSecret> {
124 self.client_secret
125 }
126
127 /// How the client authenticates at the token endpoint.
128 #[must_use]
129 pub const fn auth_method(&self) -> TokenAuthMethod {
130 self.auth_method
131 }
132
133 /// The `scope` to send with the token request, if any.
134 ///
135 /// The authorization page lets a client that wants a refresh token add
136 /// `offline_access` "to the `scope` parameter of the authorization and
137 /// token requests", so the token request carries the scope the
138 /// authorization request asked for.
139 #[must_use]
140 pub const fn scope(&self) -> Option<&'a str> {
141 self.scope
142 }
143
144 /// The RFC 8707 resource indicators this grant is bound to.
145 ///
146 /// Repeated as one `resource` member per indicator, which is the encoding
147 /// §2 of that RFC defines.
148 #[must_use]
149 pub const fn resource(&self) -> &'a ResourceParameters {
150 self.resource
151 }
152}
153
154/// Asks an authorization server to trade a code for a token.
155///
156/// One method, and one because redemption is a single act. An adapter does the
157/// HTTP; this crate decides what to ask for, and the domain decides what came
158/// back.
159///
160/// Not dyn compatible, and that is deliberate. Redemption happens once per
161/// client rather than once per request, so the call site takes `impl
162/// TokenEndpoint` and the monomorphised future costs nothing that a box would
163/// not.
164pub trait TokenEndpoint {
165 /// Trade the authorization code for a token.
166 ///
167 /// # Errors
168 ///
169 /// [`TokenEndpointError::Refused`] when the server answered RFC 6749 §5.2,
170 /// or with a body that was not a usable token. [`TokenEndpointError::Unavailable`]
171 /// when the server could not be asked or its answer could not be read.
172 fn redeem(
173 &self,
174 request: TokenRequest<'_>,
175 ) -> impl Future<Output = Result<IssuedToken, TokenEndpointError>> + Send;
176
177 /// Trade a refresh token for a new access token (RFC 6749 §6).
178 ///
179 /// The default refuses, so an endpoint written before this method existed
180 /// keeps compiling and a caller finds out at runtime, as an
181 /// [`TokenEndpointError::Unavailable`], that it cannot refresh.
182 ///
183 /// # Errors
184 ///
185 /// As [`redeem`](Self::redeem). `invalid_grant` here means the refresh
186 /// token is spent or revoked, and the caller falls back to a full
187 /// authorization.
188 fn refresh(
189 &self,
190 request: RefreshRequest<'_>,
191 ) -> impl Future<Output = Result<IssuedToken, TokenEndpointError>> + Send {
192 let _ = request;
193 core::future::ready(Err(TokenEndpointError::Unavailable(
194 "this token endpoint does not support refresh tokens".to_owned(),
195 )))
196 }
197}
198
199/// One `grant_type=refresh_token` request.
200///
201/// Borrowed, like [`TokenRequest`], so a refresh token is not copied around.
202#[derive(Debug)]
203pub struct RefreshRequest<'a> {
204 refresh_token: &'a str,
205 client_id: &'a ClientId,
206 client_secret: Option<&'a ClientSecret>,
207 resource: &'a ResourceParameters,
208 auth_method: TokenAuthMethod,
209 scope: Option<&'a str>,
210}
211
212impl<'a> RefreshRequest<'a> {
213 /// A refresh of `refresh_token` as `client`, for the resource the grant
214 /// was bound to.
215 #[must_use]
216 pub const fn new(
217 refresh_token: &'a str,
218 client: &'a RegisteredClient,
219 resource: &'a ResourceParameters,
220 scope: Option<&'a str>,
221 ) -> Self {
222 Self {
223 refresh_token,
224 client_id: client.client_id(),
225 client_secret: client.secret(),
226 resource,
227 auth_method: client.auth_method(),
228 scope,
229 }
230 }
231
232 /// The refresh token being redeemed.
233 #[must_use]
234 pub const fn refresh_token(&self) -> &'a str {
235 self.refresh_token
236 }
237
238 /// The client the refresh is made as.
239 #[must_use]
240 pub const fn client_id(&self) -> &'a ClientId {
241 self.client_id
242 }
243
244 /// The secret, for a confidential client only; omitted otherwise.
245 #[must_use]
246 pub const fn client_secret(&self) -> Option<&'a ClientSecret> {
247 self.client_secret
248 }
249
250 /// How the client authenticates at the token endpoint.
251 #[must_use]
252 pub const fn auth_method(&self) -> TokenAuthMethod {
253 self.auth_method
254 }
255
256 /// The RFC 8707 resource indicators the grant is bound to.
257 #[must_use]
258 pub const fn resource(&self) -> &'a ResourceParameters {
259 self.resource
260 }
261
262 /// The scope to ask for, if narrower than the original grant.
263 #[must_use]
264 pub const fn scope(&self) -> Option<&'a str> {
265 self.scope
266 }
267}
268
269/// Why a redemption did not produce a token.
270///
271/// `#[error]` text never carries the code, the verifier or the secret. This is
272/// the first place any of those three could reach a message somebody pastes
273/// into a bug report.
274#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
275pub enum TokenEndpointError {
276 /// The server refused, or answered with something unusable.
277 #[error(transparent)]
278 Refused(#[from] TokenResponseError),
279
280 /// The server could not be asked, or its answer could not be read.
281 #[error("the token endpoint could not be reached: {0}")]
282 Unavailable(String),
283}
284
285/// Why [`redeem`] did not produce a token.
286#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
287pub enum RedeemError {
288 /// The registration presented does not match the client that started the
289 /// exchange.
290 ///
291 /// [`redeem`] compares rather than trusting, because the client id is a
292 /// parameter the adapter is about to send: a caller that reached for the
293 /// wrong registration would otherwise produce a token request naming one
294 /// client with a `code_verifier` generated for another, and the server's
295 /// answer would be an `invalid_grant` with nothing to explain it.
296 #[error("the registration is for {presented}, but this code was issued to {expected}")]
297 ClientMismatch {
298 /// The client the code was issued to.
299 expected: ClientId,
300 /// The client the caller presented.
301 presented: ClientId,
302 },
303
304 /// The endpoint refused, or could not be reached.
305 #[error(transparent)]
306 Endpoint(#[from] TokenEndpointError),
307}
308
309/// Trade a validated authorization code for a token.
310///
311/// The code is consumed. See the module docs for why that is the single-use
312/// rule rather than a convention.
313///
314/// # Errors
315///
316/// [`RedeemError::ClientMismatch`] when the registration is not the client the
317/// code was issued to. [`RedeemError::Endpoint`] when the endpoint refused or
318/// could not be reached; that carries RFC 6749 §5.2's own error code when the
319/// server named one.
320///
321/// # Examples
322///
323/// ```
324/// # use protocol_droid::application::oauth::redeem::{RedeemError, redeem};
325/// # use protocol_droid::domain::oauth::registration::{ClientId, RegisteredClient};
326/// # async fn run(
327/// # code: protocol_droid::application::oauth::callback::AuthorizedCode,
328/// # client: RegisteredClient,
329/// # endpoint: &(impl protocol_droid::application::oauth::redeem::TokenEndpoint + Sync),
330/// # ) -> Result<(), RedeemError> {
331/// let issued = redeem(code, &client, endpoint).await?;
332/// println!("scope granted: {:?}", issued.scope());
333/// # Ok(())
334/// # }
335/// ```
336pub async fn redeem(
337 code: AuthorizedCode,
338 client: &RegisteredClient,
339 endpoint: &(impl TokenEndpoint + Sync),
340) -> Result<IssuedToken, RedeemError> {
341 redeem_with_scope(code, client, endpoint, None).await
342}
343
344/// [`redeem`], sending `scope` with the token request.
345///
346/// # Errors
347///
348/// As [`redeem`].
349pub async fn redeem_with_scope(
350 code: AuthorizedCode,
351 client: &RegisteredClient,
352 endpoint: &(impl TokenEndpoint + Sync),
353 scope: Option<&str>,
354) -> Result<IssuedToken, RedeemError> {
355 if code.client_id() != client.client_id() {
356 return Err(RedeemError::ClientMismatch {
357 expected: code.client_id().clone(),
358 presented: client.client_id().clone(),
359 });
360 }
361
362 let request = TokenRequest {
363 code: code.code(),
364 // Read back off the code rather than taken from the registration: the
365 // registration records where the client is willing to be redirected
366 // *now*, and §4.1.3 asks for where this request was actually sent.
367 redirect_uri: code.redirect_uri(),
368 verifier: code.code_verifier(),
369 client_id: code.client_id(),
370 client_secret: client.secret(),
371 resource: code.resource().token_request(),
372 auth_method: client.auth_method(),
373 scope,
374 };
375
376 endpoint
377 .redeem(request)
378 .await
379 .map_err(RedeemError::Endpoint)
380}
381
382/// Rendered only so a `TokenRequest` in a log line names its fields without
383/// their values.
384impl fmt::Display for TokenRequest<'_> {
385 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
386 write!(
387 formatter,
388 "TokenRequest {{ grant_type: authorization_code, code: …, redirect_uri: {}, \
389 code_verifier: …, client_id: {}, client_secret: {}, resource: [{}] }}",
390 self.redirect_uri,
391 self.client_id,
392 self.client_secret.is_some(),
393 self.resource.iter().collect::<Vec<_>>().join(", "),
394 )
395 }
396}
397#[cfg(test)]
398mod tests {
399 use std::assert_matches;
400 use std::future::Future;
401 use std::sync::Mutex;
402 use std::task::{Context, Poll, Waker};
403
404 use super::{RedeemError, TokenEndpoint, TokenEndpointError, TokenRequest, redeem};
405 use crate::application::oauth::callback::{
406 AuthorizationCallback, AuthorizedCode, authorize_callback,
407 };
408 use crate::application::oauth::client::{
409 AuthorizationSecrets, PendingAuthorization, SecretError, State, begin_authorization,
410 };
411 use crate::domain::ProtocolVersion;
412 use crate::domain::mcp::authorization::{AuthorizationModel, authorization_model};
413 use crate::domain::mcp::resource_metadata::ResourceUri;
414 use crate::domain::oauth::issued_token::{IssuedToken, TokenResponseError};
415 use crate::domain::oauth::pkce::CodeVerifier;
416 use crate::domain::oauth::registration::{ClientId, ClientSecret, RegisteredClient};
417 use crate::domain::oauth::resource::ResourceParameters;
418
419 const AS: &str = "https://auth.example.com";
420 const CLIENT_ID: &str = "https://client.example.com/metadata.json";
421 const REDIRECT: &str = "https://client.example.com/callback";
422 const RESOURCE: &str = "https://api.example.com/app";
423 const VERIFIER: &str = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
424 const STATE: &str = "a-state-value";
425 const GRANTED: &[u8] = br#"{"access_token":"at-1","token_type":"Bearer","expires_in":3600}"#;
426
427 /// Every error in play implements `std::error::Error`, so a test can `?`
428 /// through the lot and print whatever the engine refused with, rather than
429 /// unwrapping the first call that happens to be fallible.
430 type Outcome = Result<(), Box<dyn std::error::Error>>;
431
432 /// Drives a future that is expected never to pend.
433 ///
434 /// `#[tokio::test]` is not available under `oauth`: the tokio macros come in
435 /// with `http`, and pulling a runtime in to test fakes that return
436 /// immediately would be a heavier dependency than the thing under test.
437 /// `Waker::noop` is correct for a future that resolves on its first poll,
438 /// and returning `None` on `Pending` is what makes that assumption
439 /// checkable rather than merely stated.
440 fn now<T>(future: impl Future<Output = T>) -> Option<T> {
441 let mut future = std::pin::pin!(future);
442 match future
443 .as_mut()
444 .poll(&mut Context::from_waker(Waker::noop()))
445 {
446 Poll::Ready(output) => Some(output),
447 Poll::Pending => None,
448 }
449 }
450
451 struct Fixed;
452
453 impl AuthorizationSecrets for Fixed {
454 fn new_state(&self) -> Result<State, SecretError> {
455 Ok(State::new(STATE))
456 }
457 fn new_code_verifier(&self) -> Result<CodeVerifier, SecretError> {
458 CodeVerifier::new(VERIFIER).map_err(|_| SecretError::Rejected)
459 }
460 }
461
462 const fn model() -> AuthorizationModel {
463 authorization_model(ProtocolVersion::V2026_07_28)
464 }
465
466 /// A client registered through a metadata document, so public: no secret.
467 fn public_client() -> Result<RegisteredClient, Box<dyn std::error::Error>> {
468 Ok(RegisteredClient::from_metadata_document(
469 AS,
470 ClientId::metadata_url(CLIENT_ID)?,
471 ))
472 }
473
474 fn confidential_client() -> Result<RegisteredClient, Box<dyn std::error::Error>> {
475 Ok(RegisteredClient::dynamic(
476 AS,
477 ClientId::opaque("client-1"),
478 Some(ClientSecret::issued("s3cret")),
479 )?)
480 }
481
482 /// A validated code for `client`, made through the real path so the values
483 /// in it are the ones a live flow would carry.
484 fn authorized(client: &RegisteredClient) -> Result<AuthorizedCode, Box<dyn std::error::Error>> {
485 let resource = ResourceUri::new(RESOURCE)?;
486 let pending: PendingAuthorization = begin_authorization(
487 &model(),
488 client,
489 REDIRECT,
490 ResourceParameters::single(&resource),
491 &Fixed,
492 )?;
493 let callback = AuthorizationCallback::granted("the-code", STATE);
494 Ok(authorize_callback(&model(), &pending, &callback)?)
495 }
496
497 /// What the fake saw, copied out so it can outlive the borrow the
498 /// `TokenRequest` held.
499 #[derive(Debug)]
500 struct Recorded {
501 code: String,
502 redirect_uri: String,
503 verifier: String,
504 client_id: String,
505 client_secret: Option<String>,
506 resources: Vec<String>,
507 }
508
509 enum Outcome_ {
510 Granted,
511 Refused(TokenResponseError),
512 Unreachable,
513 }
514
515 struct Recording {
516 recorded: Mutex<Option<Recorded>>,
517 outcome: Outcome_,
518 }
519
520 impl Recording {
521 fn granting() -> Self {
522 Self {
523 recorded: Mutex::new(None),
524 outcome: Outcome_::Granted,
525 }
526 }
527
528 fn refusing(error: TokenResponseError) -> Self {
529 Self {
530 recorded: Mutex::new(None),
531 outcome: Outcome_::Refused(error),
532 }
533 }
534
535 fn unreachable() -> Self {
536 Self {
537 recorded: Mutex::new(None),
538 outcome: Outcome_::Unreachable,
539 }
540 }
541
542 fn seen(&self) -> Option<Recorded> {
543 self.recorded.lock().ok().and_then(|mut guard| guard.take())
544 }
545 }
546
547 impl TokenEndpoint for Recording {
548 // Not `async`: this fake resolves on its first poll, so an `async fn`
549 // here would be an async block with no await point in it — which is a
550 // `ready` wearing a hat.
551 fn redeem(
552 &self,
553 request: TokenRequest<'_>,
554 ) -> impl Future<Output = Result<IssuedToken, TokenEndpointError>> + Send {
555 let recorded = Recorded {
556 code: request.code().to_owned(),
557 redirect_uri: request.redirect_uri().to_owned(),
558 verifier: request.code_verifier().as_str().to_owned(),
559 client_id: request.client_id().as_str().to_owned(),
560 client_secret: request
561 .client_secret()
562 .map(|secret| secret.expose().to_owned()),
563 resources: request.resource().iter().map(str::to_owned).collect(),
564 };
565 if let Ok(mut guard) = self.recorded.lock() {
566 *guard = Some(recorded);
567 }
568
569 std::future::ready(match &self.outcome {
570 Outcome_::Granted => {
571 IssuedToken::parse(GRANTED).map_err(TokenEndpointError::Refused)
572 }
573 Outcome_::Refused(error) => Err(TokenEndpointError::Refused(error.clone())),
574 Outcome_::Unreachable => Err(TokenEndpointError::Unavailable(
575 "no route to host".to_owned(),
576 )),
577 })
578 }
579 }
580
581 /// A redeemed grant is the thing every downstream caller wants, so the
582 /// happy path gets a test that says so rather than one that only checks it
583 /// did not error.
584 #[test]
585 fn a_valid_code_becomes_a_token() -> Outcome {
586 let client = public_client()?;
587 let endpoint = Recording::granting();
588
589 let issued =
590 now(redeem(authorized(&client)?, &client, &endpoint)).ok_or("the future pended")??;
591
592 assert_eq!(issued.access_token().as_str(), "at-1");
593 Ok(())
594 }
595
596 /// §4.1.3 asks for a `redirect_uri` *identical* to the authorization
597 /// request's, and the verifier the challenge was derived from. Both come
598 /// off the code rather than off the caller for exactly that reason.
599 #[test]
600 fn the_request_is_assembled_from_the_code_not_the_caller() -> Outcome {
601 let client = public_client()?;
602 let endpoint = Recording::granting();
603
604 now(redeem(authorized(&client)?, &client, &endpoint)).ok_or("the future pended")??;
605
606 let seen = endpoint.seen().ok_or("the endpoint was never asked")?;
607 assert_eq!(seen.code, "the-code");
608 assert_eq!(seen.redirect_uri, REDIRECT);
609 assert_eq!(seen.verifier, VERIFIER);
610 assert_eq!(seen.client_id, CLIENT_ID);
611 Ok(())
612 }
613
614 /// RFC 8707 §2. Dropping the indicator here would let the server mint a
615 /// token for an audience the client never asked for.
616 #[test]
617 fn the_resource_binding_is_carried_to_the_token_request() -> Outcome {
618 let client = public_client()?;
619 let endpoint = Recording::granting();
620
621 now(redeem(authorized(&client)?, &client, &endpoint)).ok_or("the future pended")??;
622
623 let seen = endpoint.seen().ok_or("the endpoint was never asked")?;
624 assert_eq!(seen.resources, vec![RESOURCE.to_owned()]);
625 Ok(())
626 }
627
628 /// A public client has no secret, and §2.3.1 reads the member's *presence*
629 /// as a claim of confidentiality — so the adapter must be able to omit it.
630 #[test]
631 fn a_public_client_sends_no_secret() -> Outcome {
632 let client = public_client()?;
633 let endpoint = Recording::granting();
634
635 now(redeem(authorized(&client)?, &client, &endpoint)).ok_or("the future pended")??;
636
637 let seen = endpoint.seen().ok_or("the endpoint was never asked")?;
638 assert_eq!(seen.client_secret, None);
639 Ok(())
640 }
641
642 #[test]
643 fn a_confidential_client_sends_its_secret() -> Outcome {
644 let client = confidential_client()?;
645 let endpoint = Recording::granting();
646
647 now(redeem(authorized(&client)?, &client, &endpoint)).ok_or("the future pended")??;
648
649 let seen = endpoint.seen().ok_or("the endpoint was never asked")?;
650 assert_eq!(seen.client_secret.as_deref(), Some("s3cret"));
651 Ok(())
652 }
653
654 /// The check that stops a caller reaching for the wrong registration. The
655 /// alternative is a token request naming one client with a verifier
656 /// generated for another, and the server's `invalid_grant` explains
657 /// nothing about which of the two was wrong.
658 #[test]
659 fn a_registration_for_another_client_is_refused_before_the_request() -> Outcome {
660 let issued_for = confidential_client()?;
661 let endpoint = Recording::granting();
662
663 let outcome = now(redeem(
664 authorized(&issued_for)?,
665 &public_client()?,
666 &endpoint,
667 ));
668
669 assert_matches!(outcome, Some(Err(RedeemError::ClientMismatch { .. })));
670 assert!(
671 endpoint.seen().is_none(),
672 "a mismatched code was sent anyway"
673 );
674 Ok(())
675 }
676
677 /// §5.2's code reaches the caller, because `invalid_grant` and
678 /// `invalid_client` call for different responses and a bare message does not
679 /// distinguish them.
680 #[test]
681 fn the_servers_own_refusal_reaches_the_caller() -> Outcome {
682 let client = public_client()?;
683 let endpoint = Recording::refusing(TokenResponseError::AuthorizationServer {
684 error: "invalid_grant".to_owned(),
685 description: None,
686 });
687
688 let outcome = now(redeem(authorized(&client)?, &client, &endpoint));
689
690 assert_matches!(
691 outcome,
692 Some(Err(RedeemError::Endpoint(TokenEndpointError::Refused(
693 TokenResponseError::AuthorizationServer { error, ..
694 })))) if error == "invalid_grant"
695 );
696 Ok(())
697 }
698
699 /// An endpoint that could not be reached is not the same as an endpoint
700 /// that refused: the first is worth retrying later, the second is not.
701 #[test]
702 fn an_unreachable_endpoint_is_not_reported_as_a_refusal() -> Outcome {
703 let client = public_client()?;
704 let endpoint = Recording::unreachable();
705
706 let outcome = now(redeem(authorized(&client)?, &client, &endpoint));
707
708 assert_matches!(
709 outcome,
710 Some(Err(RedeemError::Endpoint(TokenEndpointError::Unavailable(
711 _
712 ))))
713 );
714 Ok(())
715 }
716
717 /// No error on any path carries the code, the verifier or the secret.
718 /// These are the three values a pasted bug report would leak.
719 #[test]
720 fn no_error_text_carries_a_credential() -> Outcome {
721 let client = confidential_client()?;
722 let endpoint = Recording::refusing(TokenResponseError::AuthorizationServer {
723 error: "invalid_grant".to_owned(),
724 description: None,
725 });
726
727 let outcome = now(redeem(authorized(&client)?, &client, &endpoint));
728
729 let rendered = match outcome {
730 Some(Err(error)) => error.to_string(),
731 _ => String::from("no error was produced"),
732 };
733 // Indexed rather than named: this assertion exists because the values
734 // must *not* appear in the error text, so echoing one in the failure
735 // message would put it in the CI log in exactly the circumstance the
736 // test was written to prevent. The index says which of the three went
737 // wrong, which is all a reader needs to look at the code.
738 for (index, secret) in ["the-code", VERIFIER, "s3cret"].iter().enumerate() {
739 assert!(
740 !rendered.contains(*secret),
741 "credential {index} leaked into the error text: {rendered}"
742 );
743 }
744 Ok(())
745 }
746}