use serde::Deserialize;
#[cfg(feature = "well-known-fetch")]
use url::Url;
#[cfg(feature = "well-known-fetch")]
use crate::error::Error;
#[cfg(feature = "well-known-fetch")]
const DEFAULT_AUTH_URL: &str = "https://accounts.ppoppo.com/oauth/authorize";
#[cfg(feature = "well-known-fetch")]
const DEFAULT_TOKEN_URL: &str = "https://accounts.ppoppo.com/oauth/token";
#[cfg(feature = "well-known-fetch")]
#[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: Option<String>,
pub(crate) resource: Option<String>,
}
#[cfg(feature = "well-known-fetch")]
impl OAuthConfig {
#[must_use]
#[allow(clippy::expect_used)] pub fn new(client_id: impl Into<String>) -> Self {
Self {
client_id: client_id.into(),
redirect_uri: None,
auth_url: DEFAULT_AUTH_URL.parse().expect("valid default URL"),
token_url: DEFAULT_TOKEN_URL.parse().expect("valid default URL"),
resource: None,
}
}
#[must_use]
pub fn with_redirect_uri(mut self, redirect_uri: impl Into<String>) -> Self {
self.redirect_uri = Some(redirect_uri.into());
self
}
#[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
}
#[must_use]
pub fn with_resource(mut self, resource: impl Into<String>) -> Self {
self.resource = Some(resource.into());
self
}
}
#[cfg(feature = "well-known-fetch")]
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>,
#[serde(default)]
pub scope: Option<String>,
}
#[cfg(feature = "well-known-fetch")]
impl AuthClient {
pub fn try_new(config: OAuthConfig) -> Result<Self, Error> {
#[cfg(not(target_arch = "wasm32"))]
let _ = rustls::crypto::ring::default_provider().install_default();
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 redirect_uri = self.config.redirect_uri.as_deref().ok_or_else(|| {
Error::OAuth {
operation: "token exchange",
status: None,
detail: "authorization_code exchange requires a redirect_uri \
(RFC 6749 §4.1.3); this client was built for refresh only"
.to_owned(),
}
})?;
let params = code_grant_form(
code,
redirect_uri,
self.config.client_id.as_str(),
code_verifier,
self.config.resource.as_deref(),
);
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}"),
})
}
}
#[cfg(feature = "well-known-fetch")]
impl crate::pas_port::PasAuthPort for AuthClient {
async fn refresh(
&self,
refresh_token: &str,
) -> Result<TokenResponse, crate::pas_port::PasFailure> {
let params = refresh_grant_form(
refresh_token,
self.config.client_id.as_str(),
self.config.resource.as_deref(),
);
self.send_classified(
self.http.post(self.config.token_url.clone()).form(¶ms),
)
.await
}
}
#[cfg(feature = "well-known-fetch")]
fn code_grant_form<'a>(
code: &'a str,
redirect_uri: &'a str,
client_id: &'a str,
code_verifier: &'a str,
resource: Option<&'a str>,
) -> Vec<(&'a str, &'a str)> {
let mut params = vec![
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", redirect_uri),
("client_id", client_id),
("code_verifier", code_verifier),
];
if let Some(r) = resource {
params.push(("resource", r));
}
params
}
#[cfg(feature = "well-known-fetch")]
fn refresh_grant_form<'a>(
refresh_token: &'a str,
client_id: &'a str,
resource: Option<&'a str>,
) -> Vec<(&'a str, &'a str)> {
let mut params = vec![
("grant_type", "refresh_token"),
("refresh_token", refresh_token),
("client_id", client_id),
];
if let Some(r) = resource {
params.push(("resource", r));
}
params
}
#[cfg(all(test, feature = "well-known-fetch"))]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn config_constructor_sets_defaults() {
let config = OAuthConfig::new("my-app").with_redirect_uri("https://my-app.com/callback");
assert_eq!(config.client_id, "my-app");
assert_eq!(config.redirect_uri.as_deref(), Some("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")
.with_redirect_uri("https://my-app.com/callback")
.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"
);
}
#[test]
fn config_resource_defaults_none_and_is_stored_verbatim() {
let base = OAuthConfig::new("cwc").with_redirect_uri("https://ppoppo.com/callback");
assert_eq!(base.resource, None, "no resource unless opted in (RCW/CTW path)");
let with = base.with_resource("http://localhost:3200");
assert_eq!(with.resource.as_deref(), Some("http://localhost:3200"));
}
#[test]
fn code_grant_omits_resource_when_absent() {
let params = code_grant_form("the-code", "https://rp/cb", "cwc", "verifier", None);
assert!(
!params.iter().any(|(k, _)| *k == "resource"),
"identity RPs (RCW/CTW) send no resource → aud stays client_id"
);
assert_eq!(params.len(), 5);
}
#[test]
fn code_grant_appends_resource_when_present() {
let r = "https://api.ppoppo.com/grpc";
let params = code_grant_form("the-code", "https://rp/cb", "cwc", "verifier", Some(r));
assert_eq!(
params.iter().find(|(k, _)| *k == "resource").map(|(_, v)| *v),
Some(r),
"resource rides authorization_code (RFC 8707 §2.2)"
);
}
#[test]
fn refresh_grant_carries_resource_so_aud_survives_the_refresh_leg() {
let r = "https://api.ppoppo.com/grpc";
let with = refresh_grant_form("rt", "cwc", Some(r));
assert_eq!(
with.iter().find(|(k, _)| *k == "resource").map(|(_, v)| *v),
Some(r)
);
let without = refresh_grant_form("rt", "cwc", None);
assert!(!without.iter().any(|(k, _)| *k == "resource"));
}
}