Skip to main content

pas_external/
oauth.rs

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/// Ppoppo Accounts `OAuth2` configuration.
10///
11/// SDK-internal as of 0.8.0 — `oidc::RelyingParty<S>` constructs this
12/// from `oidc::Config` + the discovered endpoints. Consumers reach OAuth
13/// through the OIDC RP composition root, not this builder directly.
14#[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)] // Infallible parse — URLs are compile-time constants
26    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
48/// `OAuth2` authorization client for Ppoppo Accounts.
49pub struct AuthClient {
50    config: OAuthConfig,
51    http: reqwest::Client,
52}
53
54/// Token response from PAS token endpoint.
55///
56/// `id_token` is OIDC-only (RFC 6749 token responses carry only
57/// access + refresh; OIDC Core §3.1.3.3 adds `id_token` when scope
58/// includes `openid`). [`crate::oidc::RelyingParty<S>`] reads it
59/// internally and converts to [`crate::oidc::RefreshOutcome`] /
60/// [`crate::oidc::Completion`] at the SDK boundary.
61#[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    /// Create a new Ppoppo Accounts auth client.
76    ///
77    /// Returns an error iff `reqwest::Client::builder()` cannot construct a
78    /// client with the configured timeouts (TLS init failure, OS-level
79    /// resource exhaustion). The previous `unwrap_or_default()` path silently
80    /// substituted a no-timeout client, which converted a startup failure
81    /// into a runtime hang on the first PAS call — fail loudly instead.
82    ///
83    /// # Errors
84    ///
85    /// Returns [`Error::Http`] if the underlying HTTP client cannot be built.
86    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    /// Exchange an authorization code for tokens using PKCE.
99    ///
100    /// # Errors
101    ///
102    /// Returns [`Error::Http`] on network failure, or
103    /// [`Error::OAuth`] if the token endpoint returns an error.
104    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(&params),
119        )
120        .await
121        .map_err(|f| f.into_legacy_error("token exchange"))
122    }
123
124    /// The single place in this module that reads HTTP status codes
125    /// from PAS token / exchange-code responses. The `PasAuthPort::refresh`
126    /// impl consumes the resulting [`PasFailure`] directly; the
127    /// legacy-signature inherent method `exchange_code` converts via
128    /// [`PasFailure::into_legacy_error`].
129    ///
130    /// Note: `keyset::fetch_document` performs its own status-reading
131    /// for the well-known keyset document and does not route through
132    /// here.
133    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(&params),
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}