1use serde::{Deserialize, Serialize};
2use url::Url;
3
4use crate::error::Error;
5use crate::pkce;
6use crate::types::{Ppnum, PpnumId};
7
8const DEFAULT_AUTH_URL: &str = "https://accounts.ppoppo.com/oauth/authorize";
9const DEFAULT_TOKEN_URL: &str = "https://accounts.ppoppo.com/oauth/token";
10const DEFAULT_USERINFO_URL: &str = "https://accounts.ppoppo.com/oauth/userinfo";
11
12#[derive(Debug, Clone)]
25#[non_exhaustive]
26pub struct OAuthConfig {
27 pub(crate) client_id: String,
28 pub(crate) auth_url: Url,
29 pub(crate) token_url: Url,
30 pub(crate) userinfo_url: Url,
31 pub(crate) redirect_uri: Url,
32 pub(crate) scopes: Vec<String>,
33}
34
35impl OAuthConfig {
36 #[must_use]
40 #[allow(clippy::expect_used)] pub fn new(client_id: impl Into<String>, redirect_uri: Url) -> Self {
42 Self {
43 client_id: client_id.into(),
44 redirect_uri,
45 auth_url: DEFAULT_AUTH_URL.parse().expect("valid default URL"),
46 token_url: DEFAULT_TOKEN_URL.parse().expect("valid default URL"),
47 userinfo_url: DEFAULT_USERINFO_URL.parse().expect("valid default URL"),
48 scopes: vec!["profile".into()],
49 }
50 }
51
52 #[must_use]
54 pub fn with_auth_url(mut self, url: Url) -> Self {
55 self.auth_url = url;
56 self
57 }
58
59 #[must_use]
61 pub fn with_token_url(mut self, url: Url) -> Self {
62 self.token_url = url;
63 self
64 }
65
66 #[must_use]
68 pub fn with_userinfo_url(mut self, url: Url) -> Self {
69 self.userinfo_url = url;
70 self
71 }
72
73 #[must_use]
75 pub fn with_scopes(mut self, scopes: Vec<String>) -> Self {
76 self.scopes = scopes;
77 self
78 }
79
80 #[must_use]
82 pub fn client_id(&self) -> &str {
83 &self.client_id
84 }
85
86 #[must_use]
88 pub fn auth_url(&self) -> &Url {
89 &self.auth_url
90 }
91
92 #[must_use]
94 pub fn token_url(&self) -> &Url {
95 &self.token_url
96 }
97
98 #[must_use]
100 pub fn userinfo_url(&self) -> &Url {
101 &self.userinfo_url
102 }
103
104 #[must_use]
106 pub fn redirect_uri(&self) -> &Url {
107 &self.redirect_uri
108 }
109
110 #[must_use]
112 pub fn scopes(&self) -> &[String] {
113 &self.scopes
114 }
115}
116
117pub struct AuthClient {
119 config: OAuthConfig,
120 http: reqwest::Client,
121}
122
123#[non_exhaustive]
125pub struct AuthorizationRequest {
126 pub url: String,
127 pub state: String,
128 pub code_verifier: String,
129}
130
131#[derive(Debug, Clone, Deserialize)]
138#[non_exhaustive]
139pub struct TokenResponse {
140 pub access_token: String,
141 pub token_type: String,
142 #[serde(default)]
143 pub expires_in: Option<u64>,
144 #[serde(default)]
145 pub refresh_token: Option<String>,
146 #[serde(default)]
147 pub id_token: Option<String>,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
152#[non_exhaustive]
153pub struct UserInfo {
154 pub sub: PpnumId,
155 #[serde(default)]
156 pub email: Option<String>,
157 pub ppnum: Ppnum,
158 #[serde(default)]
159 pub email_verified: Option<bool>,
160 #[serde(default, with = "time::serde::rfc3339::option")]
161 pub created_at: Option<time::OffsetDateTime>,
162}
163
164impl UserInfo {
165 #[must_use]
167 pub fn new(sub: PpnumId, ppnum: Ppnum) -> Self {
168 Self {
169 sub,
170 ppnum,
171 email: None,
172 email_verified: None,
173 created_at: None,
174 }
175 }
176
177 #[must_use]
179 pub fn with_email(mut self, email: impl Into<String>) -> Self {
180 self.email = Some(email.into());
181 self
182 }
183
184 #[must_use]
186 pub fn with_email_verified(mut self, verified: bool) -> Self {
187 self.email_verified = Some(verified);
188 self
189 }
190}
191
192impl AuthClient {
193 pub fn try_new(config: OAuthConfig) -> Result<Self, Error> {
205 let builder = reqwest::Client::builder();
206 #[cfg(not(target_arch = "wasm32"))]
207 let builder = builder
208 .timeout(std::time::Duration::from_secs(10))
209 .connect_timeout(std::time::Duration::from_secs(5));
210 Ok(Self {
211 config,
212 http: builder.build()?,
213 })
214 }
215
216 #[must_use]
222 pub fn with_http_client(config: OAuthConfig, client: reqwest::Client) -> Self {
223 Self {
224 config,
225 http: client,
226 }
227 }
228
229 #[must_use]
231 pub fn authorization_url(&self) -> AuthorizationRequest {
232 let state = pkce::generate_state();
233 let code_verifier = pkce::generate_code_verifier();
234 let code_challenge = pkce::generate_code_challenge(&code_verifier);
235 let scope = self.config.scopes.join(" ");
236
237 let mut url = self.config.auth_url.clone();
238 url.query_pairs_mut()
239 .append_pair("response_type", "code")
240 .append_pair("client_id", &self.config.client_id)
241 .append_pair("redirect_uri", self.config.redirect_uri.as_str())
242 .append_pair("state", &state)
243 .append_pair("code_challenge", &code_challenge)
244 .append_pair("code_challenge_method", "S256")
245 .append_pair("scope", &scope);
246
247 AuthorizationRequest {
248 url: url.into(),
249 state,
250 code_verifier,
251 }
252 }
253
254 pub async fn exchange_code(
261 &self,
262 code: &str,
263 code_verifier: &str,
264 ) -> Result<TokenResponse, Error> {
265 let params = [
266 ("grant_type", "authorization_code"),
267 ("code", code),
268 ("redirect_uri", self.config.redirect_uri.as_str()),
269 ("client_id", self.config.client_id.as_str()),
270 ("code_verifier", code_verifier),
271 ];
272
273 self.send_classified(
274 self.http.post(self.config.token_url.clone()).form(¶ms),
275 )
276 .await
277 .map_err(|f| f.into_legacy_error("token exchange"))
278 }
279
280 async fn send_classified<T: serde::de::DeserializeOwned>(
291 &self,
292 request: reqwest::RequestBuilder,
293 ) -> Result<T, crate::pas_port::PasFailure> {
294 use crate::pas_port::PasFailure;
295
296 let response = request
297 .send()
298 .await
299 .map_err(|e| PasFailure::Transport { detail: e.to_string() })?;
300
301 let status = response.status();
302 if status.is_server_error() {
303 let body = response.text().await.unwrap_or_default();
304 return Err(PasFailure::ServerError { status: status.as_u16(), detail: body });
305 }
306 if !status.is_success() {
307 let body = response.text().await.unwrap_or_default();
308 return Err(PasFailure::Rejected { status: status.as_u16(), detail: body });
309 }
310
311 response.json::<T>().await.map_err(|e| PasFailure::Transport {
312 detail: format!("response deserialization failed: {e}"),
313 })
314 }
315}
316
317impl crate::pas_port::PasAuthPort for AuthClient {
318 async fn refresh(
319 &self,
320 refresh_token: &str,
321 ) -> Result<TokenResponse, crate::pas_port::PasFailure> {
322 let params = [
323 ("grant_type", "refresh_token"),
324 ("refresh_token", refresh_token),
325 ("client_id", self.config.client_id.as_str()),
326 ];
327
328 self.send_classified(
329 self.http.post(self.config.token_url.clone()).form(¶ms),
330 )
331 .await
332 }
333
334 async fn userinfo(
335 &self,
336 access_token: &str,
337 ) -> Result<UserInfo, crate::pas_port::PasFailure> {
338 self.send_classified(
339 self.http
340 .get(self.config.userinfo_url.clone())
341 .bearer_auth(access_token),
342 )
343 .await
344 }
345}
346
347#[cfg(test)]
348#[allow(clippy::unwrap_used)]
349mod tests {
350 use super::*;
351
352 fn test_config() -> OAuthConfig {
353 OAuthConfig::new(
354 "test-client",
355 "https://example.com/callback".parse().unwrap(),
356 )
357 }
358
359 #[test]
360 fn test_authorization_url_contains_pkce() {
361 let client = AuthClient::try_new(test_config()).unwrap();
362 let req = client.authorization_url();
363
364 assert!(req.url.contains("code_challenge="));
365 assert!(req.url.contains("code_challenge_method=S256"));
366 assert!(req.url.contains("state="));
367 assert!(req.url.contains("response_type=code"));
368 assert!(req.url.contains("client_id=test-client"));
369 assert!(!req.code_verifier.is_empty());
370 assert!(!req.state.is_empty());
371 }
372
373 #[test]
374 fn test_authorization_url_unique_per_call() {
375 let client = AuthClient::try_new(test_config()).unwrap();
376 let req1 = client.authorization_url();
377 let req2 = client.authorization_url();
378
379 assert_ne!(req1.state, req2.state);
380 assert_ne!(req1.code_verifier, req2.code_verifier);
381 }
382
383 #[test]
384 fn test_config_constructor() {
385 let config = OAuthConfig::new("my-app", "https://my-app.com/callback".parse().unwrap());
386
387 assert_eq!(config.client_id(), "my-app");
388 assert_eq!(
389 config.redirect_uri().as_str(),
390 "https://my-app.com/callback"
391 );
392 assert_eq!(
393 config.auth_url().as_str(),
394 "https://accounts.ppoppo.com/oauth/authorize"
395 );
396 }
397
398 #[test]
399 fn test_config_with_overrides() {
400 let config = OAuthConfig::new("my-app", "https://my-app.com/callback".parse().unwrap())
401 .with_auth_url("https://custom.example.com/authorize".parse().unwrap())
402 .with_scopes(vec!["profile".into(), "email".into()]);
403
404 assert_eq!(
405 config.auth_url().as_str(),
406 "https://custom.example.com/authorize"
407 );
408 assert_eq!(config.scopes(), &["profile", "email"]);
409 }
410}