use core::future::Future;
use core::pin::Pin;
use std::sync::Arc;
use url::Url;
use crate::adapters::secondary::oauth::http_body::read_capped;
use crate::adapters::secondary::oauth::redeem::HttpTokenEndpoint;
use crate::application::oauth::callback::AuthorizationCallback;
use crate::application::oauth::environment::{EnvironmentError, Fetched, OAuthEnvironment};
pub trait UserAgent: Send + Sync {
fn authorize<'a>(
&'a self,
authorization_url: &'a str,
) -> Pin<Box<dyn Future<Output = Result<AuthorizationCallback, EnvironmentError>> + Send + 'a>>;
}
#[derive(Debug, Clone)]
pub struct FollowsRedirect {
http: reqwest::Client,
}
impl FollowsRedirect {
pub fn new() -> Result<Self, String> {
reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.map(|http| Self { http })
.map_err(|error| error.to_string())
}
}
impl UserAgent for FollowsRedirect {
fn authorize<'a>(
&'a self,
authorization_url: &'a str,
) -> Pin<Box<dyn Future<Output = Result<AuthorizationCallback, EnvironmentError>> + Send + 'a>>
{
Box::pin(async move {
let response = self
.http
.get(authorization_url)
.send()
.await
.map_err(|error| EnvironmentError::Declined(error.to_string()))?;
let location = response
.headers()
.get(reqwest::header::LOCATION)
.and_then(|value| value.to_str().ok())
.ok_or_else(|| {
EnvironmentError::Declined(format!(
"the authorization endpoint answered {} with no Location",
response.status().as_u16()
))
})?;
callback_from(location)
})
}
}
pub fn callback_from(location: &str) -> Result<AuthorizationCallback, EnvironmentError> {
let url =
Url::parse(location).map_err(|error| EnvironmentError::Declined(error.to_string()))?;
let mut code = None;
let mut error = None;
let mut state = String::new();
let mut issuer: Option<Arc<str>> = None;
for (key, value) in url.query_pairs() {
match key.as_ref() {
"code" => code = Some(value.into_owned()),
"error" => error = Some(value.into_owned()),
"state" => state = value.into_owned(),
"iss" => issuer = Some(Arc::from(value.as_ref())),
_ => {}
}
}
let callback = match (code, error) {
(Some(code), _) => AuthorizationCallback::granted(code, state),
(None, Some(error)) => AuthorizationCallback::refused(error, state),
(None, None) => {
return Err(EnvironmentError::Declined(
"the redirect carried neither a code nor an error".to_owned(),
));
}
};
Ok(callback.with_issuer(issuer))
}
#[derive(Clone)]
pub struct HttpOAuthEnvironment {
http: reqwest::Client,
agent: Arc<dyn UserAgent>,
}
impl core::fmt::Debug for HttpOAuthEnvironment {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("HttpOAuthEnvironment")
.finish_non_exhaustive()
}
}
impl HttpOAuthEnvironment {
pub fn new(agent: Arc<dyn UserAgent>) -> Result<Self, String> {
let http = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|error| error.to_string())?;
Ok(Self { http, agent })
}
}
async fn read(response: reqwest::Response) -> Result<Fetched, EnvironmentError> {
let status = response.status().as_u16();
let body = read_capped(response)
.await
.map_err(EnvironmentError::Unavailable)?;
Ok(Fetched { status, body })
}
impl OAuthEnvironment for HttpOAuthEnvironment {
type Token = HttpTokenEndpoint;
fn token_endpoint(&self, url: &str) -> HttpTokenEndpoint {
HttpTokenEndpoint::new(url).with_http_client(self.http.clone())
}
async fn get(&self, url: &str) -> Result<Fetched, EnvironmentError> {
let response = self
.http
.get(url)
.header(reqwest::header::ACCEPT, "application/json")
.send()
.await
.map_err(|error| EnvironmentError::Unavailable(error.to_string()))?;
read(response).await
}
async fn post_json(&self, url: &str, body: &str) -> Result<Fetched, EnvironmentError> {
let response = self
.http
.post(url)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.header(reqwest::header::ACCEPT, "application/json")
.body(body.to_owned())
.send()
.await
.map_err(|error| EnvironmentError::Unavailable(error.to_string()))?;
read(response).await
}
async fn authorize(
&self,
authorization_url: &str,
) -> Result<AuthorizationCallback, EnvironmentError> {
self.agent.authorize(authorization_url).await
}
}
#[cfg(test)]
mod tests {
use super::callback_from;
#[test]
fn a_grant_carries_its_code_state_and_issuer() -> Result<(), Box<dyn std::error::Error>> {
let callback = callback_from(
"http://localhost:3000/callback?code=abc&state=xyz&iss=https%3A%2F%2Fauth.example.com",
)?;
assert_eq!(callback.code(), Some("abc"));
assert_eq!(callback.state(), Some("xyz"));
assert_eq!(callback.issuer(), Some("https://auth.example.com"));
Ok(())
}
#[test]
fn a_refusal_carries_its_error() -> Result<(), Box<dyn std::error::Error>> {
let callback = callback_from("http://localhost:3000/callback?error=access_denied&state=s")?;
assert_eq!(callback.error(), Some("access_denied"));
Ok(())
}
#[test]
fn a_redirect_with_neither_is_declined() {
assert!(callback_from("http://localhost:3000/callback").is_err());
}
}