Skip to main content

pas_external/
oauth.rs

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/// Ppoppo Accounts `OAuth2` configuration.
13///
14/// Required fields are constructor parameters — no runtime "missing field" errors.
15///
16/// ```rust,ignore
17/// use ppoppo_sdk::OAuthConfig;
18///
19/// let config = OAuthConfig::new("my-client-id", "https://my-app.com/callback".parse()?);
20/// // Optional overrides via chaining:
21/// let config = config
22///     .with_auth_url("https://custom.example.com/authorize".parse()?);
23/// ```
24#[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    /// Create a new OAuth2 configuration.
37    ///
38    /// Required fields are parameters — compile-time enforcement, no `Result`.
39    #[must_use]
40    #[allow(clippy::expect_used)] // Infallible parse — URLs are compile-time constants
41    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    /// Override the PAS authorization endpoint.
53    #[must_use]
54    pub fn with_auth_url(mut self, url: Url) -> Self {
55        self.auth_url = url;
56        self
57    }
58
59    /// Override the PAS token endpoint.
60    #[must_use]
61    pub fn with_token_url(mut self, url: Url) -> Self {
62        self.token_url = url;
63        self
64    }
65
66    /// Override the PAS userinfo endpoint.
67    #[must_use]
68    pub fn with_userinfo_url(mut self, url: Url) -> Self {
69        self.userinfo_url = url;
70        self
71    }
72
73    /// Override the OAuth2 scopes (default: `["profile"]`).
74    #[must_use]
75    pub fn with_scopes(mut self, scopes: Vec<String>) -> Self {
76        self.scopes = scopes;
77        self
78    }
79
80    /// `OAuth2` client ID.
81    #[must_use]
82    pub fn client_id(&self) -> &str {
83        &self.client_id
84    }
85
86    /// Authorization endpoint URL.
87    #[must_use]
88    pub fn auth_url(&self) -> &Url {
89        &self.auth_url
90    }
91
92    /// Token exchange endpoint URL.
93    #[must_use]
94    pub fn token_url(&self) -> &Url {
95        &self.token_url
96    }
97
98    /// User info endpoint URL.
99    #[must_use]
100    pub fn userinfo_url(&self) -> &Url {
101        &self.userinfo_url
102    }
103
104    /// `OAuth2` redirect URI.
105    #[must_use]
106    pub fn redirect_uri(&self) -> &Url {
107        &self.redirect_uri
108    }
109
110    /// Requested `OAuth2` scopes.
111    #[must_use]
112    pub fn scopes(&self) -> &[String] {
113        &self.scopes
114    }
115}
116
117/// `OAuth2` authorization client for Ppoppo Accounts.
118pub struct AuthClient {
119    config: OAuthConfig,
120    http: reqwest::Client,
121}
122
123/// Authorization URL with PKCE parameters to store in session.
124#[non_exhaustive]
125pub struct AuthorizationRequest {
126    pub url: String,
127    pub state: String,
128    pub code_verifier: String,
129}
130
131/// Token response from PAS token endpoint.
132///
133/// `id_token` is OIDC-only (RFC 6749 token responses carry only
134/// access + refresh; OIDC Core §3.1.3.3 adds `id_token` when scope
135/// includes `openid`). [`crate::oidc::RelyingParty<S>`] reads it
136/// internally; OAuth-only consumers ignore it.
137#[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/// User info from Ppoppo Accounts userinfo endpoint.
151#[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    /// Create a new `UserInfo` with required fields.
166    #[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    /// Set the email.
178    #[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    /// Set the email_verified flag.
185    #[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    /// Create a new Ppoppo Accounts auth client.
194    ///
195    /// Returns an error iff `reqwest::Client::builder()` cannot construct a
196    /// client with the configured timeouts (TLS init failure, OS-level
197    /// resource exhaustion). The previous `unwrap_or_default()` path silently
198    /// substituted a no-timeout client, which converted a startup failure
199    /// into a runtime hang on the first PAS call — fail loudly instead.
200    ///
201    /// # Errors
202    ///
203    /// Returns [`Error::Http`] if the underlying HTTP client cannot be built.
204    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    /// Build with a caller-supplied HTTP client.
217    ///
218    /// Use this when sharing a `reqwest::Client` across multiple SDK clients
219    /// for connection-pool reuse, or when you need custom TLS / proxy / timeout
220    /// configuration. This constructor never fails.
221    #[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    /// Generate an authorization URL with PKCE parameters.
230    #[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    /// Exchange an authorization code for tokens using PKCE.
255    ///
256    /// # Errors
257    ///
258    /// Returns [`Error::Http`] on network failure, or
259    /// [`Error::OAuth`] if the token endpoint returns an error.
260    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(&params),
275        )
276        .await
277        .map_err(|f| f.into_legacy_error("token exchange"))
278    }
279
280    /// The single place in this module that reads HTTP status codes
281    /// from PAS token / userinfo / exchange-code responses. The
282    /// `PasAuthPort` impl methods (`refresh`, `userinfo`) consume the
283    /// resulting [`PasFailure`] directly; the legacy-signature
284    /// inherent method `exchange_code` converts via
285    /// [`PasFailure::into_legacy_error`].
286    ///
287    /// Note: `keyset::fetch_document` performs its own status-reading
288    /// for the well-known keyset document and does not route through
289    /// here. Future RFCs may unify those paths.
290    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(&params),
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}