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)]
133#[non_exhaustive]
134pub struct TokenResponse {
135 pub access_token: String,
136 pub token_type: String,
137 #[serde(default)]
138 pub expires_in: Option<u64>,
139 #[serde(default)]
140 pub refresh_token: Option<String>,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
145#[non_exhaustive]
146pub struct UserInfo {
147 pub sub: PpnumId,
148 #[serde(default)]
149 pub email: Option<String>,
150 pub ppnum: Ppnum,
151 #[serde(default)]
152 pub email_verified: Option<bool>,
153 #[serde(default, with = "time::serde::rfc3339::option")]
154 pub created_at: Option<time::OffsetDateTime>,
155 #[serde(default)]
165 pub session_version: Option<i64>,
166}
167
168impl UserInfo {
169 #[must_use]
171 pub fn new(sub: PpnumId, ppnum: Ppnum) -> Self {
172 Self {
173 sub,
174 ppnum,
175 email: None,
176 email_verified: None,
177 created_at: None,
178 session_version: None,
179 }
180 }
181
182 #[must_use]
184 pub fn with_email(mut self, email: impl Into<String>) -> Self {
185 self.email = Some(email.into());
186 self
187 }
188
189 #[must_use]
191 pub fn with_email_verified(mut self, verified: bool) -> Self {
192 self.email_verified = Some(verified);
193 self
194 }
195
196 #[must_use]
198 pub fn with_session_version(mut self, sv: i64) -> Self {
199 self.session_version = Some(sv);
200 self
201 }
202}
203
204impl AuthClient {
205 pub fn try_new(config: OAuthConfig) -> Result<Self, Error> {
217 let builder = reqwest::Client::builder();
218 #[cfg(not(target_arch = "wasm32"))]
219 let builder = builder
220 .timeout(std::time::Duration::from_secs(10))
221 .connect_timeout(std::time::Duration::from_secs(5));
222 Ok(Self {
223 config,
224 http: builder.build()?,
225 })
226 }
227
228 #[must_use]
234 pub fn with_http_client(config: OAuthConfig, client: reqwest::Client) -> Self {
235 Self {
236 config,
237 http: client,
238 }
239 }
240
241 #[must_use]
243 pub fn authorization_url(&self) -> AuthorizationRequest {
244 let state = pkce::generate_state();
245 let code_verifier = pkce::generate_code_verifier();
246 let code_challenge = pkce::generate_code_challenge(&code_verifier);
247 let scope = self.config.scopes.join(" ");
248
249 let mut url = self.config.auth_url.clone();
250 url.query_pairs_mut()
251 .append_pair("response_type", "code")
252 .append_pair("client_id", &self.config.client_id)
253 .append_pair("redirect_uri", self.config.redirect_uri.as_str())
254 .append_pair("state", &state)
255 .append_pair("code_challenge", &code_challenge)
256 .append_pair("code_challenge_method", "S256")
257 .append_pair("scope", &scope);
258
259 AuthorizationRequest {
260 url: url.into(),
261 state,
262 code_verifier,
263 }
264 }
265
266 pub async fn exchange_code(
273 &self,
274 code: &str,
275 code_verifier: &str,
276 ) -> Result<TokenResponse, Error> {
277 let params = [
278 ("grant_type", "authorization_code"),
279 ("code", code),
280 ("redirect_uri", self.config.redirect_uri.as_str()),
281 ("client_id", self.config.client_id.as_str()),
282 ("code_verifier", code_verifier),
283 ];
284
285 self.send_and_deserialize(
286 self.http.post(self.config.token_url.clone()).form(¶ms),
287 "token exchange",
288 )
289 .await
290 }
291
292 pub async fn refresh_token(&self, refresh_token: &str) -> Result<TokenResponse, Error> {
299 let params = [
300 ("grant_type", "refresh_token"),
301 ("refresh_token", refresh_token),
302 ("client_id", self.config.client_id.as_str()),
303 ];
304
305 self.send_and_deserialize(
306 self.http.post(self.config.token_url.clone()).form(¶ms),
307 "token refresh",
308 )
309 .await
310 }
311
312 pub async fn get_user_info(&self, access_token: &str) -> Result<UserInfo, Error> {
319 self.send_and_deserialize(
320 self.http
321 .get(self.config.userinfo_url.clone())
322 .bearer_auth(access_token),
323 "userinfo request",
324 )
325 .await
326 }
327
328 async fn send_and_deserialize<T: serde::de::DeserializeOwned>(
329 &self,
330 request: reqwest::RequestBuilder,
331 operation: &'static str,
332 ) -> Result<T, Error> {
333 let response = request.send().await?;
334
335 if !response.status().is_success() {
336 let status = response.status().as_u16();
337 let body = response.text().await.unwrap_or_default();
338 return Err(Error::OAuth {
339 operation,
340 status: Some(status),
341 detail: body,
342 });
343 }
344
345 response.json::<T>().await.map_err(|e| Error::OAuth {
346 operation,
347 status: None,
348 detail: format!("response deserialization failed: {e}"),
349 })
350 }
351}
352
353#[cfg(test)]
354#[allow(clippy::unwrap_used)]
355mod tests {
356 use super::*;
357
358 fn test_config() -> OAuthConfig {
359 OAuthConfig::new(
360 "test-client",
361 "https://example.com/callback".parse().unwrap(),
362 )
363 }
364
365 #[test]
366 fn test_authorization_url_contains_pkce() {
367 let client = AuthClient::try_new(test_config()).unwrap();
368 let req = client.authorization_url();
369
370 assert!(req.url.contains("code_challenge="));
371 assert!(req.url.contains("code_challenge_method=S256"));
372 assert!(req.url.contains("state="));
373 assert!(req.url.contains("response_type=code"));
374 assert!(req.url.contains("client_id=test-client"));
375 assert!(!req.code_verifier.is_empty());
376 assert!(!req.state.is_empty());
377 }
378
379 #[test]
380 fn test_authorization_url_unique_per_call() {
381 let client = AuthClient::try_new(test_config()).unwrap();
382 let req1 = client.authorization_url();
383 let req2 = client.authorization_url();
384
385 assert_ne!(req1.state, req2.state);
386 assert_ne!(req1.code_verifier, req2.code_verifier);
387 }
388
389 #[test]
390 fn test_config_constructor() {
391 let config = OAuthConfig::new("my-app", "https://my-app.com/callback".parse().unwrap());
392
393 assert_eq!(config.client_id(), "my-app");
394 assert_eq!(
395 config.redirect_uri().as_str(),
396 "https://my-app.com/callback"
397 );
398 assert_eq!(
399 config.auth_url().as_str(),
400 "https://accounts.ppoppo.com/oauth/authorize"
401 );
402 }
403
404 #[test]
405 fn test_config_with_overrides() {
406 let config = OAuthConfig::new("my-app", "https://my-app.com/callback".parse().unwrap())
407 .with_auth_url("https://custom.example.com/authorize".parse().unwrap())
408 .with_scopes(vec!["profile".into(), "email".into()]);
409
410 assert_eq!(
411 config.auth_url().as_str(),
412 "https://custom.example.com/authorize"
413 );
414 assert_eq!(config.scopes(), &["profile", "email"]);
415 }
416}