Skip to main content

pas_external/
oauth.rs

1//! PAS OAuth2 wire types + HTTP adapter, split across two feature tiers.
2//!
3//! - [`TokenResponse`] — the PAS token-endpoint response DTO. Available
4//!   at the `oauth` tier because `pas_port` / `session_liveness` /
5//!   `oidc::state_store` consume it without the HTTP client.
6//! - [`OAuthConfig`] + [`AuthClient`] — the production HTTP adapter.
7//!   Gated on `well-known-fetch`, the tier of its sole consumer
8//!   ([`oidc::RelyingParty`](crate::oidc::RelyingParty), whose
9//!   `new` is the only place `AuthClient` is ever constructed). Compiling
10//!   the adapter at the lower `oauth` tier orphaned it under the default
11//!   feature set, since the RP composition root that uses it was not
12//!   compiled there.
13
14use serde::Deserialize;
15#[cfg(feature = "well-known-fetch")]
16use url::Url;
17
18#[cfg(feature = "well-known-fetch")]
19use crate::error::Error;
20
21#[cfg(feature = "well-known-fetch")]
22const DEFAULT_AUTH_URL: &str = "https://accounts.ppoppo.com/oauth/authorize";
23#[cfg(feature = "well-known-fetch")]
24const DEFAULT_TOKEN_URL: &str = "https://accounts.ppoppo.com/oauth/token";
25
26/// Ppoppo Accounts `OAuth2` configuration.
27///
28/// SDK-internal as of 0.8.0 — `oidc::RelyingParty<S>` constructs this
29/// from `oidc::Config` + the discovered endpoints. Consumers reach OAuth
30/// through the OIDC RP composition root, not this builder directly.
31#[cfg(feature = "well-known-fetch")]
32#[derive(Debug, Clone)]
33#[non_exhaustive]
34pub struct OAuthConfig {
35    pub(crate) client_id: String,
36    pub(crate) auth_url: Url,
37    pub(crate) token_url: Url,
38    /// RFC 6749 §4.1.3 requires the token-leg `redirect_uri` to be identical
39    /// to the one sent at authorize. Kept as a raw `String` for the same
40    /// reason as [`Self::resource`] — a `Url` round-trip re-adds the trailing
41    /// slash the `url` crate normalizes onto a host-only URI, which would
42    /// break that identity for a loopback redirect written without a path.
43    ///
44    /// `None` for a **refresh-only** client: the `refresh_token` grant sends
45    /// no `redirect_uri` at all (RFC 6749 §6), so a client that will never
46    /// exchange a code does not need one. `exchange_code` rejects that
47    /// configuration rather than silently omitting a required parameter.
48    pub(crate) redirect_uri: Option<String>,
49    /// RFC 8707 resource indicator sent on the token / refresh legs. Kept as
50    /// a raw `String` — never a `Url` — because PAS matches it byte-for-byte
51    /// against `OAUTH_RESOURCE_INDICATORS`, and `Url::as_str()` re-adds the
52    /// trailing slash the `url` crate normalizes onto a host-only URL
53    /// (`http://localhost:3200` → `…3200/`), which would miss the match and
54    /// mint `aud = client_id` instead (the RCW/CTW silent-401 bug class).
55    pub(crate) resource: Option<String>,
56}
57
58#[cfg(feature = "well-known-fetch")]
59impl OAuthConfig {
60    #[must_use]
61    #[allow(clippy::expect_used)] // Infallible parse — URLs are compile-time constants
62    pub fn new(client_id: impl Into<String>) -> Self {
63        Self {
64            client_id: client_id.into(),
65            redirect_uri: None,
66            auth_url: DEFAULT_AUTH_URL.parse().expect("valid default URL"),
67            token_url: DEFAULT_TOKEN_URL.parse().expect("valid default URL"),
68            resource: None,
69        }
70    }
71
72    /// Set the redirect URI for the `authorization_code` leg, verbatim. See
73    /// [`Self::redirect_uri`] — the string is forwarded byte-for-byte.
74    #[must_use]
75    pub fn with_redirect_uri(mut self, redirect_uri: impl Into<String>) -> Self {
76        self.redirect_uri = Some(redirect_uri.into());
77        self
78    }
79
80    #[must_use]
81    pub fn with_auth_url(mut self, url: Url) -> Self {
82        self.auth_url = url;
83        self
84    }
85
86    #[must_use]
87    pub fn with_token_url(mut self, url: Url) -> Self {
88        self.token_url = url;
89        self
90    }
91
92    /// Set the RFC 8707 resource indicator (an absolute URI, as a raw string)
93    /// to send on the token / refresh legs. See [`Self::resource`].
94    #[must_use]
95    pub fn with_resource(mut self, resource: impl Into<String>) -> Self {
96        self.resource = Some(resource.into());
97        self
98    }
99}
100
101/// `OAuth2` authorization client for Ppoppo Accounts.
102#[cfg(feature = "well-known-fetch")]
103pub struct AuthClient {
104    config: OAuthConfig,
105    http: reqwest::Client,
106}
107
108/// Token response from PAS token endpoint.
109///
110/// `id_token` is OIDC-only (RFC 6749 token responses carry only
111/// access + refresh; OIDC Core §3.1.3.3 adds `id_token` when scope
112/// includes `openid`). [`crate::oidc::RelyingParty<S>`] reads it
113/// internally and converts to [`crate::oidc::RefreshOutcome`] /
114/// [`crate::oidc::Completion`] at the SDK boundary.
115#[derive(Debug, Clone, Deserialize)]
116#[non_exhaustive]
117pub struct TokenResponse {
118    pub access_token: String,
119    pub token_type: String,
120    #[serde(default)]
121    pub expires_in: Option<u64>,
122    #[serde(default)]
123    pub refresh_token: Option<String>,
124    #[serde(default)]
125    pub id_token: Option<String>,
126    /// The scope actually granted, space-delimited (RFC 6749 §5.1).
127    ///
128    /// **Optional by specification**, and absence is meaningful rather than
129    /// missing data: the field may be omitted precisely when the granted
130    /// scope is identical to the requested one. `None` therefore means
131    /// "granted as requested" and must be treated as success — see
132    /// [`crate::scope_grant::ensure_covers`], the one place that reads it.
133    ///
134    /// (Today's PAS always echoes, but this SDK is published and must speak
135    /// the RFC rather than the behavior of one server.)
136    #[serde(default)]
137    pub scope: Option<String>,
138}
139
140#[cfg(feature = "well-known-fetch")]
141impl AuthClient {
142    /// Create a new Ppoppo Accounts auth client.
143    ///
144    /// Returns an error iff `reqwest::Client::builder()` cannot construct a
145    /// client with the configured timeouts (TLS init failure, OS-level
146    /// resource exhaustion). The previous `unwrap_or_default()` path silently
147    /// substituted a no-timeout client, which converted a startup failure
148    /// into a runtime hang on the first PAS call — fail loudly instead.
149    ///
150    /// # Errors
151    ///
152    /// Returns [`Error::Http`] if the underlying HTTP client cannot be built.
153    pub fn try_new(config: OAuthConfig) -> Result<Self, Error> {
154        // reqwest is built with `rustls-no-provider` (keeps aws-lc-rs out of
155        // consumer graphs); a process-level CryptoProvider must exist before
156        // the Client builds. Idempotent; wasm reqwest uses browser fetch().
157        #[cfg(not(target_arch = "wasm32"))]
158        let _ = rustls::crypto::ring::default_provider().install_default();
159        let builder = reqwest::Client::builder();
160        #[cfg(not(target_arch = "wasm32"))]
161        let builder = builder
162            .timeout(std::time::Duration::from_secs(10))
163            .connect_timeout(std::time::Duration::from_secs(5));
164        Ok(Self {
165            config,
166            http: builder.build()?,
167        })
168    }
169
170    /// Exchange an authorization code for tokens using PKCE.
171    ///
172    /// # Errors
173    ///
174    /// Returns [`Error::Http`] on network failure, or
175    /// [`Error::OAuth`] if the token endpoint returns an error.
176    pub async fn exchange_code(
177        &self,
178        code: &str,
179        code_verifier: &str,
180    ) -> Result<TokenResponse, Error> {
181        // RFC 6749 §4.1.3 makes `redirect_uri` REQUIRED on this leg whenever
182        // it was present at authorize — which it always is here. A
183        // refresh-only client reaching this method is a wiring bug; fail
184        // loudly rather than send a request PAS would reject as
185        // `invalid_grant` for a reason the caller cannot see.
186        let redirect_uri = self.config.redirect_uri.as_deref().ok_or_else(|| {
187            Error::OAuth {
188                operation: "token exchange",
189                status: None,
190                detail: "authorization_code exchange requires a redirect_uri \
191                         (RFC 6749 §4.1.3); this client was built for refresh only"
192                    .to_owned(),
193            }
194        })?;
195
196        let params = code_grant_form(
197            code,
198            redirect_uri,
199            self.config.client_id.as_str(),
200            code_verifier,
201            self.config.resource.as_deref(),
202        );
203
204        self.send_classified(
205            self.http.post(self.config.token_url.clone()).form(&params),
206        )
207        .await
208        .map_err(|f| f.into_legacy_error("token exchange"))
209    }
210
211    /// The single place in this module that reads HTTP status codes
212    /// from PAS token / exchange-code responses. The `PasAuthPort::refresh`
213    /// impl consumes the resulting [`PasFailure`] directly; the
214    /// legacy-signature inherent method `exchange_code` converts via
215    /// [`PasFailure::into_legacy_error`].
216    ///
217    /// Note: `keyset::fetch_document` performs its own status-reading
218    /// for the well-known keyset document and does not route through
219    /// here.
220    async fn send_classified<T: serde::de::DeserializeOwned>(
221        &self,
222        request: reqwest::RequestBuilder,
223    ) -> Result<T, crate::pas_port::PasFailure> {
224        use crate::pas_port::PasFailure;
225
226        let response = request
227            .send()
228            .await
229            .map_err(|e| PasFailure::Transport { detail: e.to_string() })?;
230
231        let status = response.status();
232        if status.is_server_error() {
233            let body = response.text().await.unwrap_or_default();
234            return Err(PasFailure::ServerError { status: status.as_u16(), detail: body });
235        }
236        if !status.is_success() {
237            let body = response.text().await.unwrap_or_default();
238            return Err(PasFailure::Rejected { status: status.as_u16(), detail: body });
239        }
240
241        response.json::<T>().await.map_err(|e| PasFailure::Transport {
242            detail: format!("response deserialization failed: {e}"),
243        })
244    }
245}
246
247#[cfg(feature = "well-known-fetch")]
248impl crate::pas_port::PasAuthPort for AuthClient {
249    async fn refresh(
250        &self,
251        refresh_token: &str,
252    ) -> Result<TokenResponse, crate::pas_port::PasFailure> {
253        let params = refresh_grant_form(
254            refresh_token,
255            self.config.client_id.as_str(),
256            self.config.resource.as_deref(),
257        );
258
259        self.send_classified(
260            self.http.post(self.config.token_url.clone()).form(&params),
261        )
262        .await
263    }
264}
265
266// ────────────────────────────────────────────────────────────────────────
267// Token-request form builders — extracted for boundary-test introspection
268// ────────────────────────────────────────────────────────────────────────
269
270/// Build the `authorization_code` grant form params. Pulled out as a free
271/// function (mirroring `oidc::build_authorize_url`) so the RFC 8707 resource
272/// boundary test can assert the wire params without a live token endpoint.
273/// The `resource` indicator, when present, rides RFC 8707 §2.2 so the minted
274/// `aud` matches the resource bound at authorize.
275#[cfg(feature = "well-known-fetch")]
276fn code_grant_form<'a>(
277    code: &'a str,
278    redirect_uri: &'a str,
279    client_id: &'a str,
280    code_verifier: &'a str,
281    resource: Option<&'a str>,
282) -> Vec<(&'a str, &'a str)> {
283    let mut params = vec![
284        ("grant_type", "authorization_code"),
285        ("code", code),
286        ("redirect_uri", redirect_uri),
287        ("client_id", client_id),
288        ("code_verifier", code_verifier),
289    ];
290    if let Some(r) = resource {
291        params.push(("resource", r));
292    }
293    params
294}
295
296/// Build the `refresh_token` grant form params. `resource` rides RFC 8707
297/// §2.1: a PCS-bound client MUST re-send it on refresh or the refreshed
298/// token's `aud` falls back to `client_id` and every request 401s after the
299/// first hour (the silent-401 trap this SDK's docstrings warn against).
300#[cfg(feature = "well-known-fetch")]
301fn refresh_grant_form<'a>(
302    refresh_token: &'a str,
303    client_id: &'a str,
304    resource: Option<&'a str>,
305) -> Vec<(&'a str, &'a str)> {
306    let mut params = vec![
307        ("grant_type", "refresh_token"),
308        ("refresh_token", refresh_token),
309        ("client_id", client_id),
310    ];
311    if let Some(r) = resource {
312        params.push(("resource", r));
313    }
314    params
315}
316
317#[cfg(all(test, feature = "well-known-fetch"))]
318#[allow(clippy::unwrap_used)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn config_constructor_sets_defaults() {
324        let config = OAuthConfig::new("my-app").with_redirect_uri("https://my-app.com/callback");
325
326        assert_eq!(config.client_id, "my-app");
327        assert_eq!(config.redirect_uri.as_deref(), Some("https://my-app.com/callback"));
328        assert_eq!(
329            config.auth_url.as_str(),
330            "https://accounts.ppoppo.com/oauth/authorize"
331        );
332        assert_eq!(
333            config.token_url.as_str(),
334            "https://accounts.ppoppo.com/oauth/token"
335        );
336    }
337
338    #[test]
339    fn config_with_overrides_swap_endpoints() {
340        let config = OAuthConfig::new("my-app")
341            .with_redirect_uri("https://my-app.com/callback")
342            .with_auth_url("https://custom.example.com/authorize".parse().unwrap())
343            .with_token_url("https://custom.example.com/token".parse().unwrap());
344
345        assert_eq!(
346            config.auth_url.as_str(),
347            "https://custom.example.com/authorize"
348        );
349        assert_eq!(
350            config.token_url.as_str(),
351            "https://custom.example.com/token"
352        );
353    }
354
355    #[test]
356    fn config_resource_defaults_none_and_is_stored_verbatim() {
357        let base = OAuthConfig::new("cwc").with_redirect_uri("https://ppoppo.com/callback");
358        assert_eq!(base.resource, None, "no resource unless opted in (RCW/CTW path)");
359
360        // Stored byte-for-byte — NOT round-tripped through `Url` (no trailing
361        // slash added to a host-only URI). This is the dev flag-day value.
362        let with = base.with_resource("http://localhost:3200");
363        assert_eq!(with.resource.as_deref(), Some("http://localhost:3200"));
364    }
365
366    #[test]
367    fn code_grant_omits_resource_when_absent() {
368        let params = code_grant_form("the-code", "https://rp/cb", "cwc", "verifier", None);
369        assert!(
370            !params.iter().any(|(k, _)| *k == "resource"),
371            "identity RPs (RCW/CTW) send no resource → aud stays client_id"
372        );
373        assert_eq!(params.len(), 5);
374    }
375
376    #[test]
377    fn code_grant_appends_resource_when_present() {
378        let r = "https://api.ppoppo.com/grpc";
379        let params = code_grant_form("the-code", "https://rp/cb", "cwc", "verifier", Some(r));
380        assert_eq!(
381            params.iter().find(|(k, _)| *k == "resource").map(|(_, v)| *v),
382            Some(r),
383            "resource rides authorization_code (RFC 8707 §2.2)"
384        );
385    }
386
387    #[test]
388    fn refresh_grant_carries_resource_so_aud_survives_the_refresh_leg() {
389        let r = "https://api.ppoppo.com/grpc";
390        // The silent-401 trap: without this, the refreshed aud drops to
391        // client_id after the first hour and every PCS request 401s.
392        let with = refresh_grant_form("rt", "cwc", Some(r));
393        assert_eq!(
394            with.iter().find(|(k, _)| *k == "resource").map(|(_, v)| *v),
395            Some(r)
396        );
397        // And omitted for identity RPs.
398        let without = refresh_grant_form("rt", "cwc", None);
399        assert!(!without.iter().any(|(k, _)| *k == "resource"));
400    }
401}