use serde::Deserialize;
use url::Url;
use crate::error::Error;
const DEFAULT_AUTH_URL: &str = "https://accounts.ppoppo.com/oauth/authorize";
const DEFAULT_TOKEN_URL: &str = "https://accounts.ppoppo.com/oauth/token";
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct OAuthConfig {
pub(crate) client_id: String,
pub(crate) auth_url: Url,
pub(crate) token_url: Url,
pub(crate) redirect_uri: Url,
}
impl OAuthConfig {
#[must_use]
#[allow(clippy::expect_used)] pub fn new(client_id: impl Into<String>, redirect_uri: Url) -> Self {
Self {
client_id: client_id.into(),
redirect_uri,
auth_url: DEFAULT_AUTH_URL.parse().expect("valid default URL"),
token_url: DEFAULT_TOKEN_URL.parse().expect("valid default URL"),
}
}
#[must_use]
pub fn with_auth_url(mut self, url: Url) -> Self {
self.auth_url = url;
self
}
#[must_use]
pub fn with_token_url(mut self, url: Url) -> Self {
self.token_url = url;
self
}
}
pub struct AuthClient {
config: OAuthConfig,
http: reqwest::Client,
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct TokenResponse {
pub access_token: String,
pub token_type: String,
#[serde(default)]
pub expires_in: Option<u64>,
#[serde(default)]
pub refresh_token: Option<String>,
#[serde(default)]
pub id_token: Option<String>,
}
impl AuthClient {
pub fn try_new(config: OAuthConfig) -> Result<Self, Error> {
let builder = reqwest::Client::builder();
#[cfg(not(target_arch = "wasm32"))]
let builder = builder
.timeout(std::time::Duration::from_secs(10))
.connect_timeout(std::time::Duration::from_secs(5));
Ok(Self {
config,
http: builder.build()?,
})
}
pub async fn exchange_code(
&self,
code: &str,
code_verifier: &str,
) -> Result<TokenResponse, Error> {
let params = [
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", self.config.redirect_uri.as_str()),
("client_id", self.config.client_id.as_str()),
("code_verifier", code_verifier),
];
self.send_classified(
self.http.post(self.config.token_url.clone()).form(¶ms),
)
.await
.map_err(|f| f.into_legacy_error("token exchange"))
}
async fn send_classified<T: serde::de::DeserializeOwned>(
&self,
request: reqwest::RequestBuilder,
) -> Result<T, crate::pas_port::PasFailure> {
use crate::pas_port::PasFailure;
let response = request
.send()
.await
.map_err(|e| PasFailure::Transport { detail: e.to_string() })?;
let status = response.status();
if status.is_server_error() {
let body = response.text().await.unwrap_or_default();
return Err(PasFailure::ServerError { status: status.as_u16(), detail: body });
}
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(PasFailure::Rejected { status: status.as_u16(), detail: body });
}
response.json::<T>().await.map_err(|e| PasFailure::Transport {
detail: format!("response deserialization failed: {e}"),
})
}
}
impl crate::pas_port::PasAuthPort for AuthClient {
async fn refresh(
&self,
refresh_token: &str,
) -> Result<TokenResponse, crate::pas_port::PasFailure> {
let params = [
("grant_type", "refresh_token"),
("refresh_token", refresh_token),
("client_id", self.config.client_id.as_str()),
];
self.send_classified(
self.http.post(self.config.token_url.clone()).form(¶ms),
)
.await
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn config_constructor_sets_defaults() {
let config = OAuthConfig::new("my-app", "https://my-app.com/callback".parse().unwrap());
assert_eq!(config.client_id, "my-app");
assert_eq!(config.redirect_uri.as_str(), "https://my-app.com/callback");
assert_eq!(
config.auth_url.as_str(),
"https://accounts.ppoppo.com/oauth/authorize"
);
assert_eq!(
config.token_url.as_str(),
"https://accounts.ppoppo.com/oauth/token"
);
}
#[test]
fn config_with_overrides_swap_endpoints() {
let config = OAuthConfig::new("my-app", "https://my-app.com/callback".parse().unwrap())
.with_auth_url("https://custom.example.com/authorize".parse().unwrap())
.with_token_url("https://custom.example.com/token".parse().unwrap());
assert_eq!(
config.auth_url.as_str(),
"https://custom.example.com/authorize"
);
assert_eq!(
config.token_url.as_str(),
"https://custom.example.com/token"
);
}
}