protocol_droid/adapters/secondary/oauth/
environment.rs1use 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
21pub trait UserAgent: Send + Sync {
23 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#[derive(Debug, Clone)]
41pub struct FollowsRedirect {
42 http: reqwest::Client,
43}
44
45impl FollowsRedirect {
46 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
88pub 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#[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 pub fn new(agent: Arc<dyn UserAgent>) -> Result<Self, String> {
143 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}