1use serde::Deserialize;
2use url::Url;
3
4use crate::error::Error;
5
6const DEFAULT_AUTH_URL: &str = "https://accounts.ppoppo.com/oauth/authorize";
7const DEFAULT_TOKEN_URL: &str = "https://accounts.ppoppo.com/oauth/token";
8
9#[derive(Debug, Clone)]
15#[non_exhaustive]
16pub struct OAuthConfig {
17 pub(crate) client_id: String,
18 pub(crate) auth_url: Url,
19 pub(crate) token_url: Url,
20 pub(crate) redirect_uri: Url,
21}
22
23impl OAuthConfig {
24 #[must_use]
25 #[allow(clippy::expect_used)] pub fn new(client_id: impl Into<String>, redirect_uri: Url) -> Self {
27 Self {
28 client_id: client_id.into(),
29 redirect_uri,
30 auth_url: DEFAULT_AUTH_URL.parse().expect("valid default URL"),
31 token_url: DEFAULT_TOKEN_URL.parse().expect("valid default URL"),
32 }
33 }
34
35 #[must_use]
36 pub fn with_auth_url(mut self, url: Url) -> Self {
37 self.auth_url = url;
38 self
39 }
40
41 #[must_use]
42 pub fn with_token_url(mut self, url: Url) -> Self {
43 self.token_url = url;
44 self
45 }
46}
47
48pub struct AuthClient {
50 config: OAuthConfig,
51 http: reqwest::Client,
52}
53
54#[derive(Debug, Clone, Deserialize)]
62#[non_exhaustive]
63pub struct TokenResponse {
64 pub access_token: String,
65 pub token_type: String,
66 #[serde(default)]
67 pub expires_in: Option<u64>,
68 #[serde(default)]
69 pub refresh_token: Option<String>,
70 #[serde(default)]
71 pub id_token: Option<String>,
72}
73
74impl AuthClient {
75 pub fn try_new(config: OAuthConfig) -> Result<Self, Error> {
87 let builder = reqwest::Client::builder();
88 #[cfg(not(target_arch = "wasm32"))]
89 let builder = builder
90 .timeout(std::time::Duration::from_secs(10))
91 .connect_timeout(std::time::Duration::from_secs(5));
92 Ok(Self {
93 config,
94 http: builder.build()?,
95 })
96 }
97
98 pub async fn exchange_code(
105 &self,
106 code: &str,
107 code_verifier: &str,
108 ) -> Result<TokenResponse, Error> {
109 let params = [
110 ("grant_type", "authorization_code"),
111 ("code", code),
112 ("redirect_uri", self.config.redirect_uri.as_str()),
113 ("client_id", self.config.client_id.as_str()),
114 ("code_verifier", code_verifier),
115 ];
116
117 self.send_classified(
118 self.http.post(self.config.token_url.clone()).form(¶ms),
119 )
120 .await
121 .map_err(|f| f.into_legacy_error("token exchange"))
122 }
123
124 async fn send_classified<T: serde::de::DeserializeOwned>(
134 &self,
135 request: reqwest::RequestBuilder,
136 ) -> Result<T, crate::pas_port::PasFailure> {
137 use crate::pas_port::PasFailure;
138
139 let response = request
140 .send()
141 .await
142 .map_err(|e| PasFailure::Transport { detail: e.to_string() })?;
143
144 let status = response.status();
145 if status.is_server_error() {
146 let body = response.text().await.unwrap_or_default();
147 return Err(PasFailure::ServerError { status: status.as_u16(), detail: body });
148 }
149 if !status.is_success() {
150 let body = response.text().await.unwrap_or_default();
151 return Err(PasFailure::Rejected { status: status.as_u16(), detail: body });
152 }
153
154 response.json::<T>().await.map_err(|e| PasFailure::Transport {
155 detail: format!("response deserialization failed: {e}"),
156 })
157 }
158}
159
160impl crate::pas_port::PasAuthPort for AuthClient {
161 async fn refresh(
162 &self,
163 refresh_token: &str,
164 ) -> Result<TokenResponse, crate::pas_port::PasFailure> {
165 let params = [
166 ("grant_type", "refresh_token"),
167 ("refresh_token", refresh_token),
168 ("client_id", self.config.client_id.as_str()),
169 ];
170
171 self.send_classified(
172 self.http.post(self.config.token_url.clone()).form(¶ms),
173 )
174 .await
175 }
176}
177
178#[cfg(test)]
179#[allow(clippy::unwrap_used)]
180mod tests {
181 use super::*;
182
183 #[test]
184 fn config_constructor_sets_defaults() {
185 let config = OAuthConfig::new("my-app", "https://my-app.com/callback".parse().unwrap());
186
187 assert_eq!(config.client_id, "my-app");
188 assert_eq!(config.redirect_uri.as_str(), "https://my-app.com/callback");
189 assert_eq!(
190 config.auth_url.as_str(),
191 "https://accounts.ppoppo.com/oauth/authorize"
192 );
193 assert_eq!(
194 config.token_url.as_str(),
195 "https://accounts.ppoppo.com/oauth/token"
196 );
197 }
198
199 #[test]
200 fn config_with_overrides_swap_endpoints() {
201 let config = OAuthConfig::new("my-app", "https://my-app.com/callback".parse().unwrap())
202 .with_auth_url("https://custom.example.com/authorize".parse().unwrap())
203 .with_token_url("https://custom.example.com/token".parse().unwrap());
204
205 assert_eq!(
206 config.auth_url.as_str(),
207 "https://custom.example.com/authorize"
208 );
209 assert_eq!(
210 config.token_url.as_str(),
211 "https://custom.example.com/token"
212 );
213 }
214}