Skip to main content

protocol_droid/adapters/secondary/oauth/
environment.rs

1//! The authorization flow's outside world, over `reqwest`.
2//!
3//! [`HttpOAuthEnvironment`] does the three HTTP things the flow asks —
4//! `GET` a document, `POST` registration JSON, redeem a code — and delegates
5//! the one thing that is not HTTP, sending the user somewhere, to a
6//! [`UserAgent`]. A deployment with a browser supplies its own agent;
7//! [`FollowsRedirect`] is the headless one, for an authorization server that
8//! approves without asking.
9
10use core::future::Future;
11use core::pin::Pin;
12use std::sync::Arc;
13
14use url::Url;
15
16use crate::adapters::secondary::oauth::http_body::read_capped;
17use crate::adapters::secondary::oauth::redeem::HttpTokenEndpoint;
18use crate::application::oauth::callback::AuthorizationCallback;
19use crate::application::oauth::environment::{EnvironmentError, Fetched, OAuthEnvironment};
20
21/// Sends the user to an authorization URL and reports where they came back.
22pub trait UserAgent: Send + Sync {
23    /// Takes the user through `authorization_url` and returns the callback.
24    ///
25    /// # Errors
26    ///
27    /// [`EnvironmentError::Declined`] when no callback arrived.
28    fn authorize<'a>(
29        &'a self,
30        authorization_url: &'a str,
31    ) -> Pin<Box<dyn Future<Output = Result<AuthorizationCallback, EnvironmentError>> + Send + 'a>>;
32}
33
34/// A user agent for an authorization server that approves without asking.
35///
36/// `GET`s the authorization URL without following redirects and reads the
37/// callback out of the `Location` it answers with. For tests, CI, and servers
38/// that authenticate the client some other way; it is not a way to authorize a
39/// real user, who has to be shown a consent screen.
40#[derive(Debug, Clone)]
41pub struct FollowsRedirect {
42    http: reqwest::Client,
43}
44
45impl FollowsRedirect {
46    /// A headless agent.
47    ///
48    /// # Errors
49    ///
50    /// The `reqwest` builder's failure, as a string.
51    pub fn new() -> Result<Self, String> {
52        reqwest::Client::builder()
53            .redirect(reqwest::redirect::Policy::none())
54            .build()
55            .map(|http| Self { http })
56            .map_err(|error| error.to_string())
57    }
58}
59
60impl UserAgent for FollowsRedirect {
61    fn authorize<'a>(
62        &'a self,
63        authorization_url: &'a str,
64    ) -> Pin<Box<dyn Future<Output = Result<AuthorizationCallback, EnvironmentError>> + Send + 'a>>
65    {
66        Box::pin(async move {
67            let response = self
68                .http
69                .get(authorization_url)
70                .send()
71                .await
72                .map_err(|error| EnvironmentError::Declined(error.to_string()))?;
73            let location = response
74                .headers()
75                .get(reqwest::header::LOCATION)
76                .and_then(|value| value.to_str().ok())
77                .ok_or_else(|| {
78                    EnvironmentError::Declined(format!(
79                        "the authorization endpoint answered {} with no Location",
80                        response.status().as_u16()
81                    ))
82                })?;
83            callback_from(location)
84        })
85    }
86}
87
88/// Reads an authorization response out of the URL the user was sent back to.
89///
90/// # Errors
91///
92/// [`EnvironmentError::Declined`] when `location` is not a URL or carries
93/// neither a code nor an error.
94pub fn callback_from(location: &str) -> Result<AuthorizationCallback, EnvironmentError> {
95    let url =
96        Url::parse(location).map_err(|error| EnvironmentError::Declined(error.to_string()))?;
97    let mut code = None;
98    let mut error = None;
99    let mut state = String::new();
100    let mut issuer: Option<Arc<str>> = None;
101    for (key, value) in url.query_pairs() {
102        match key.as_ref() {
103            "code" => code = Some(value.into_owned()),
104            "error" => error = Some(value.into_owned()),
105            "state" => state = value.into_owned(),
106            "iss" => issuer = Some(Arc::from(value.as_ref())),
107            _ => {}
108        }
109    }
110    let callback = match (code, error) {
111        (Some(code), _) => AuthorizationCallback::granted(code, state),
112        (None, Some(error)) => AuthorizationCallback::refused(error, state),
113        (None, None) => {
114            return Err(EnvironmentError::Declined(
115                "the redirect carried neither a code nor an error".to_owned(),
116            ));
117        }
118    };
119    Ok(callback.with_issuer(issuer))
120}
121
122/// [`OAuthEnvironment`] over HTTP, with a pluggable user agent.
123#[derive(Clone)]
124pub struct HttpOAuthEnvironment {
125    http: reqwest::Client,
126    agent: Arc<dyn UserAgent>,
127}
128
129impl core::fmt::Debug for HttpOAuthEnvironment {
130    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
131        f.debug_struct("HttpOAuthEnvironment")
132            .finish_non_exhaustive()
133    }
134}
135
136impl HttpOAuthEnvironment {
137    /// An environment that sends users through `agent`.
138    ///
139    /// # Errors
140    ///
141    /// The `reqwest` builder's failure, as a string.
142    pub fn new(agent: Arc<dyn UserAgent>) -> Result<Self, String> {
143        // Redirects are not followed: a discovery document that redirects
144        // somewhere else is not the document the protocol named, and an
145        // authorization server's redirect to the callback is the agent's to
146        // read, not the HTTP client's to chase.
147        let http = reqwest::Client::builder()
148            .redirect(reqwest::redirect::Policy::none())
149            .build()
150            .map_err(|error| error.to_string())?;
151        Ok(Self { http, agent })
152    }
153}
154
155async fn read(response: reqwest::Response) -> Result<Fetched, EnvironmentError> {
156    let status = response.status().as_u16();
157    let body = read_capped(response)
158        .await
159        .map_err(EnvironmentError::Unavailable)?;
160    Ok(Fetched { status, body })
161}
162
163impl OAuthEnvironment for HttpOAuthEnvironment {
164    type Token = HttpTokenEndpoint;
165
166    fn token_endpoint(&self, url: &str) -> HttpTokenEndpoint {
167        HttpTokenEndpoint::new(url).with_http_client(self.http.clone())
168    }
169
170    async fn get(&self, url: &str) -> Result<Fetched, EnvironmentError> {
171        let response = self
172            .http
173            .get(url)
174            .header(reqwest::header::ACCEPT, "application/json")
175            .send()
176            .await
177            .map_err(|error| EnvironmentError::Unavailable(error.to_string()))?;
178        read(response).await
179    }
180
181    async fn post_json(&self, url: &str, body: &str) -> Result<Fetched, EnvironmentError> {
182        let response = self
183            .http
184            .post(url)
185            .header(reqwest::header::CONTENT_TYPE, "application/json")
186            .header(reqwest::header::ACCEPT, "application/json")
187            .body(body.to_owned())
188            .send()
189            .await
190            .map_err(|error| EnvironmentError::Unavailable(error.to_string()))?;
191        read(response).await
192    }
193
194    async fn authorize(
195        &self,
196        authorization_url: &str,
197    ) -> Result<AuthorizationCallback, EnvironmentError> {
198        self.agent.authorize(authorization_url).await
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::callback_from;
205
206    #[test]
207    fn a_grant_carries_its_code_state_and_issuer() -> Result<(), Box<dyn std::error::Error>> {
208        let callback = callback_from(
209            "http://localhost:3000/callback?code=abc&state=xyz&iss=https%3A%2F%2Fauth.example.com",
210        )?;
211        assert_eq!(callback.code(), Some("abc"));
212        assert_eq!(callback.state(), Some("xyz"));
213        assert_eq!(callback.issuer(), Some("https://auth.example.com"));
214        Ok(())
215    }
216
217    #[test]
218    fn a_refusal_carries_its_error() -> Result<(), Box<dyn std::error::Error>> {
219        let callback = callback_from("http://localhost:3000/callback?error=access_denied&state=s")?;
220        assert_eq!(callback.error(), Some("access_denied"));
221        Ok(())
222    }
223
224    #[test]
225    fn a_redirect_with_neither_is_declined() {
226        assert!(callback_from("http://localhost:3000/callback").is_err());
227    }
228}