protocol_droid/adapters/secondary/oauth/redeem.rs
1//! Talking to a token endpoint over HTTP.
2//!
3//! RFC 6749 §4.1.3: form-encode the exchange, read back §5.1 or §5.2. What
4//! this adapter deliberately does **not** do is retry.
5//!
6//! # No retries, and not because retries are fragile
7//!
8//! RFC 6749 §4.1.2 makes an authorization code single-use, so a retry after a
9//! dropped connection may arrive *after* the server honoured the first attempt
10//! and be refused with `invalid_grant`. A client that retried automatically
11//! would then report a failure for a code that had already been spent, and the
12//! only way to get a token would be to make the user authorize again. Not
13//! retrying is the behaviour that leaves a recoverable situation recoverable.
14//!
15//! # Client authentication goes in the body
16//!
17//! RFC 6749 §2.3.1 permits HTTP Basic, `client_secret_post`, or both. This
18//! adapter uses the body, and the reason is [`TokenRequest::client_secret`]'s
19//! contract: a public client registered through a client identity metadata
20//! document has no secret, and the member must be *absent* rather than empty —
21//! its presence is the statement that the client is confidential. Basic auth
22//! cannot express that without a scheme Basic auth does not have.
23
24use std::sync::Arc;
25
26use crate::adapters::secondary::oauth::http_body::{describe, read_capped};
27use crate::application::oauth::redeem::{
28 RefreshRequest, TokenEndpoint, TokenEndpointError, TokenRequest,
29};
30use crate::domain::oauth::issued_token::{IssuedToken, TokenResponseError};
31use crate::domain::oauth::registration::{ClientSecret, TokenAuthMethod};
32use crate::domain::oauth::resource::ResourceParameters;
33
34/// Redeems authorization codes at one token endpoint.
35///
36/// [`Clone`] because a caller will hold this across the user-visible round trip
37/// — or hand it to something that outlives the call that built it — and the
38/// reqwest client underneath pools connections worth sharing.
39#[derive(Debug, Clone)]
40pub struct HttpTokenEndpoint {
41 endpoint: Arc<str>,
42 http: reqwest::Client,
43}
44
45impl HttpTokenEndpoint {
46 /// An endpoint to post token requests to.
47 ///
48 /// The HTTP client is built once, so a per-request TLS handshake and a
49 /// fresh pool do not happen on the authorization path. No timeout is set
50 /// here: an endpoint that stops answering must eventually be reported as
51 /// unavailable, and the transport's own timeout is where a deployment
52 /// decides how patient to be.
53 #[must_use]
54 pub fn new(endpoint: impl AsRef<str>) -> Self {
55 Self {
56 endpoint: Arc::from(endpoint.as_ref()),
57 http: reqwest::Client::new(),
58 }
59 }
60
61 /// Replaces the HTTP client, for a caller that needs a timeout, a proxy or
62 /// custom TLS settings.
63 ///
64 /// There is deliberately no `with_timeout` shortcut: `reqwest`'s builder
65 /// returns a `Result`, and a shortcut would either swallow that error or
66 /// force every caller through a `Result` for a knob they can already reach.
67 #[must_use]
68 pub fn with_http_client(mut self, http: reqwest::Client) -> Self {
69 self.http = http;
70 self
71 }
72
73 /// The endpoint this one posts to.
74 #[must_use]
75 pub fn endpoint(&self) -> &str {
76 &self.endpoint
77 }
78}
79
80impl TokenEndpoint for HttpTokenEndpoint {
81 async fn refresh(
82 &self,
83 request: RefreshRequest<'_>,
84 ) -> Result<IssuedToken, TokenEndpointError> {
85 let mut form = vec![
86 ("grant_type", "refresh_token"),
87 ("refresh_token", request.refresh_token()),
88 ];
89 self.post(
90 &mut form,
91 request.client_id().as_str(),
92 request.client_secret().map(ClientSecret::expose),
93 request.auth_method(),
94 request.resource(),
95 request.scope(),
96 )
97 .await
98 }
99
100 async fn redeem(&self, request: TokenRequest<'_>) -> Result<IssuedToken, TokenEndpointError> {
101 let mut form = vec![
102 ("grant_type", "authorization_code"),
103 ("code", request.code()),
104 ("redirect_uri", request.redirect_uri()),
105 ("code_verifier", request.code_verifier().as_str()),
106 ];
107 self.post(
108 &mut form,
109 request.client_id().as_str(),
110 request.client_secret().map(ClientSecret::expose),
111 request.auth_method(),
112 request.resource(),
113 request.scope(),
114 )
115 .await
116 }
117}
118
119impl HttpTokenEndpoint {
120 /// Adds client authentication, `resource` and `scope` to `form`, posts it,
121 /// and reads the answer.
122 async fn post<'a>(
123 &self,
124 form: &mut Vec<(&'a str, &'a str)>,
125 client_id: &'a str,
126 client_secret: Option<&'a str>,
127 auth_method: TokenAuthMethod,
128 resource: &'a ResourceParameters,
129 scope: Option<&'a str>,
130 ) -> Result<IssuedToken, TokenEndpointError> {
131 // RFC 6749 §2.3.1. `client_secret_basic` puts both halves in the
132 // `Authorization` header, each `application/x-www-form-urlencoded`
133 // first, and leaves `client_id` out of the body; `client_secret_post`
134 // puts both in the body; a public client sends its id and no secret.
135 // An absent secret is never sent empty: the member's presence is a
136 // claim that the client is confidential.
137 let mut basic = None;
138 match (auth_method, client_secret) {
139 (TokenAuthMethod::ClientSecretBasic, Some(secret)) => {
140 basic = Some(basic_credentials(client_id, secret));
141 }
142 (TokenAuthMethod::ClientSecretPost, Some(secret)) => {
143 form.push(("client_id", client_id));
144 form.push(("client_secret", secret));
145 }
146 _ => form.push(("client_id", client_id)),
147 }
148
149 // RFC 8707 §2: one `resource` member per indicator, repeated rather
150 // than joined.
151 for indicator in resource.iter() {
152 form.push(("resource", indicator));
153 }
154 if let Some(scope) = scope {
155 form.push(("scope", scope));
156 }
157
158 let mut post = self.http.post(&*self.endpoint).form(&*form);
159 if let Some(credentials) = basic {
160 post = post.header(reqwest::header::AUTHORIZATION, credentials);
161 }
162 let response = post
163 .send()
164 .await
165 .map_err(|failure| TokenEndpointError::Unavailable(describe(failure)))?;
166
167 let status = response.status();
168 let body = read_capped(response)
169 .await
170 .map_err(TokenEndpointError::Unavailable)?;
171
172 // The body's own shape decides, not the status. A server answering 200
173 // with a §5.2 error body must not be read as a token, and one
174 // answering 400 with a §5.1 body is broken in a way the status alone
175 // does not describe. `IssuedToken::parse` checks for the `error` member
176 // first, so this is a single decision rather than two that can disagree.
177 if !status.is_success() {
178 // Try to recover the server's own reason, because `invalid_grant`
179 // and `invalid_client` call for different responses from the caller
180 // and a bare status code distinguishes neither. A body that is not
181 // a §5.2 error at all still falls back to the status.
182 return match IssuedToken::parse(&body) {
183 Err(refused @ TokenResponseError::AuthorizationServer { .. }) => {
184 Err(TokenEndpointError::Refused(refused))
185 }
186 _ => Err(TokenEndpointError::Unavailable(format!(
187 "token endpoint answered {}",
188 status.as_u16(),
189 ))),
190 };
191 }
192
193 IssuedToken::parse(&body).map_err(TokenEndpointError::Refused)
194 }
195}
196
197/// `Basic base64(urlencode(id) ":" urlencode(secret))`, per RFC 6749 §2.3.1.
198fn basic_credentials(id: &str, secret: &str) -> String {
199 use base64::Engine as _;
200 let encode =
201 |text: &str| url::form_urlencoded::byte_serialize(text.as_bytes()).collect::<String>();
202 let pair = format!("{}:{}", encode(id), encode(secret));
203 format!(
204 "Basic {}",
205 base64::engine::general_purpose::STANDARD.encode(pair.as_bytes())
206 )
207}
208
209#[cfg(test)]
210mod tests;