Skip to main content

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;