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#[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/// User info from Ppoppo Accounts userinfo endpoint.
144#[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    /// Break-glass session_version for this account (#005 spec).
156    ///
157    /// Populated by PAS for Human-entity tokens; `None` for AI agents and
158    /// for accounts that don't carry a human identity. Read by the
159    /// OAuth callback when persisting [`NewSession::sv`][ns] and by
160    /// [`SvAwareSessionResolver`][svar] on the refresh-and-recheck path.
161    ///
162    /// [ns]: crate::middleware::NewSession
163    /// [svar]: crate::middleware::SvAwareSessionResolver
164    #[serde(default)]
165    pub session_version: Option<i64>,
166}
167
168impl UserInfo {
169    /// Create a new `UserInfo` with required fields.
170    #[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    /// Set the email.
183    #[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    /// Set the email_verified flag.
190    #[must_use]
191    pub fn with_email_verified(mut self, verified: bool) -> Self {
192        self.email_verified = Some(verified);
193        self
194    }
195
196    /// Set the break-glass session_version (#005).
197    #[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    /// Create a new Ppoppo Accounts auth client.
206    ///
207    /// Returns an error iff `reqwest::Client::builder()` cannot construct a
208    /// client with the configured timeouts (TLS init failure, OS-level
209    /// resource exhaustion). The previous `unwrap_or_default()` path silently
210    /// substituted a no-timeout client, which converted a startup failure
211    /// into a runtime hang on the first PAS call — fail loudly instead.
212    ///
213    /// # Errors
214    ///
215    /// Returns [`Error::Http`] if the underlying HTTP client cannot be built.
216    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    /// Build with a caller-supplied HTTP client.
229    ///
230    /// Use this when sharing a `reqwest::Client` across multiple SDK clients
231    /// for connection-pool reuse, or when you need custom TLS / proxy / timeout
232    /// configuration. This constructor never fails.
233    #[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    /// Generate an authorization URL with PKCE parameters.
242    #[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    /// Exchange an authorization code for tokens using PKCE.
267    ///
268    /// # Errors
269    ///
270    /// Returns [`Error::Http`] on network failure, or
271    /// [`Error::OAuth`] if the token endpoint returns an error.
272    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(&params),
287            "token exchange",
288        )
289        .await
290    }
291
292    /// Exchange a refresh token for new tokens.
293    ///
294    /// # Errors
295    ///
296    /// Returns [`Error::Http`] on network failure, or
297    /// [`Error::OAuth`] if the token endpoint returns an error.
298    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(&params),
307            "token refresh",
308        )
309        .await
310    }
311
312    /// Fetch user info using an access token.
313    ///
314    /// # Errors
315    ///
316    /// Returns [`Error::Http`] on network failure, or
317    /// [`Error::OAuth`] if the userinfo endpoint returns an error.
318    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}