1use 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#[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 pub(crate) redirect_uri: Option<String>,
49 pub(crate) resource: Option<String>,
56}
57
58#[cfg(feature = "well-known-fetch")]
59impl OAuthConfig {
60 #[must_use]
61 #[allow(clippy::expect_used)] 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 #[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 #[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#[cfg(feature = "well-known-fetch")]
103pub struct AuthClient {
104 config: OAuthConfig,
105 http: reqwest::Client,
106}
107
108#[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 #[serde(default)]
137 pub scope: Option<String>,
138}
139
140#[cfg(feature = "well-known-fetch")]
141impl AuthClient {
142 pub fn try_new(config: OAuthConfig) -> Result<Self, Error> {
154 #[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 pub async fn exchange_code(
177 &self,
178 code: &str,
179 code_verifier: &str,
180 ) -> Result<TokenResponse, Error> {
181 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(¶ms),
206 )
207 .await
208 .map_err(|f| f.into_legacy_error("token exchange"))
209 }
210
211 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(¶ms),
261 )
262 .await
263 }
264}
265
266#[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#[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 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 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 let without = refresh_grant_form("rt", "cwc", None);
399 assert!(!without.iter().any(|(k, _)| *k == "resource"));
400 }
401}