1use base64::Engine as _;
4use bytes::Bytes;
5use cratefield_core::{HttpClient, HttpError};
6use http::{HeaderMap, Method, Request, header};
7use serde::Deserialize;
8use thiserror::Error;
9
10use crate::config::{ClientAuth, ProviderConfig, form_encode};
11
12#[derive(Debug, Error)]
14pub enum OAuthError {
15 #[error("transport: {0}")]
17 Transport(#[from] HttpError),
18 #[error(transparent)]
21 Provider(#[from] Box<ProviderError>),
22 #[error("unexpected token response: {0}")]
24 Decode(String),
25 #[error("oauth client is misconfigured: {0}")]
28 Config(String),
29 #[error("no revocation endpoint is configured for this provider")]
32 NoRevokeEndpoint,
33}
34
35impl OAuthError {
36 #[must_use]
40 pub fn is_invalid_grant(&self) -> bool {
41 matches!(self, OAuthError::Provider(error) if error.code == "invalid_grant")
42 }
43}
44
45#[derive(Error)]
51#[error("provider answered {status} ({code}): {description}")]
52pub struct ProviderError {
53 pub status: u16,
55 pub code: String,
57 pub description: String,
59 pub body: String,
61 pub headers: HeaderMap,
63}
64
65impl std::fmt::Debug for ProviderError {
66 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 f.debug_struct("ProviderError")
70 .field("status", &self.status)
71 .field("code", &self.code)
72 .field("description", &self.description)
73 .field(
74 "body",
75 &format_args!("<redacted {} bytes>", self.body.len()),
76 )
77 .field("headers", &self.headers)
78 .finish()
79 }
80}
81
82#[derive(Clone, Deserialize)]
87pub struct TokenResponse {
88 pub access_token: String,
90 pub token_type: Option<String>,
92 #[serde(default, deserialize_with = "de_expiry")]
94 pub expires_in: i64,
95 pub refresh_token: Option<String>,
97 #[serde(default, deserialize_with = "de_expiry_opt")]
100 pub refresh_token_expires_in: Option<i64>,
101 #[serde(default)]
103 pub scope: String,
104}
105
106impl std::fmt::Debug for TokenResponse {
107 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110 f.debug_struct("TokenResponse")
111 .field("access_token", &"<redacted>")
112 .field("token_type", &self.token_type)
113 .field("expires_in", &self.expires_in)
114 .field(
115 "refresh_token",
116 &self.refresh_token.as_ref().map(|_| "<redacted>"),
117 )
118 .field("refresh_token_expires_in", &self.refresh_token_expires_in)
119 .field("scope", &self.scope)
120 .finish()
121 }
122}
123
124fn de_expiry<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<i64, D::Error> {
128 Ok(de_expiry_opt(deserializer)?.unwrap_or(0))
129}
130
131fn de_expiry_opt<'de, D: serde::Deserializer<'de>>(
132 deserializer: D,
133) -> Result<Option<i64>, D::Error> {
134 deserializer.deserialize_any(Expiry)
135}
136
137struct Expiry;
138
139impl serde::de::Visitor<'_> for Expiry {
140 type Value = Option<i64>;
141
142 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143 f.write_str("an integer or a numeric string of seconds")
144 }
145
146 fn visit_unit<E: serde::de::Error>(self) -> Result<Self::Value, E> {
147 Ok(None)
148 }
149
150 fn visit_i64<E: serde::de::Error>(self, seconds: i64) -> Result<Self::Value, E> {
151 Ok(Some(seconds))
152 }
153
154 fn visit_u64<E: serde::de::Error>(self, seconds: u64) -> Result<Self::Value, E> {
155 i64::try_from(seconds)
156 .map(Some)
157 .map_err(serde::de::Error::custom)
158 }
159
160 fn visit_str<E: serde::de::Error>(self, seconds: &str) -> Result<Self::Value, E> {
161 seconds
162 .trim()
163 .parse::<i64>()
164 .map(Some)
165 .map_err(serde::de::Error::custom)
166 }
167}
168
169impl TokenResponse {
170 #[must_use]
175 pub fn rotated_refresh_token<'a>(&'a self, previous: &'a str) -> &'a str {
176 self.refresh_token.as_deref().unwrap_or(previous)
177 }
178}
179
180pub struct OAuthClient<'a> {
183 http: &'a dyn HttpClient,
184 config: &'a ProviderConfig,
185}
186
187impl<'a> OAuthClient<'a> {
188 #[must_use]
190 pub fn new(http: &'a dyn HttpClient, config: &'a ProviderConfig) -> Self {
191 Self { http, config }
192 }
193
194 pub async fn exchange_code(
205 &self,
206 code: &str,
207 redirect_uri: &str,
208 pkce_verifier: Option<&str>,
209 ) -> Result<TokenResponse, OAuthError> {
210 let mut form: Vec<(&str, String)> = vec![
211 ("grant_type", "authorization_code".to_owned()),
212 ("code", code.to_owned()),
213 ("redirect_uri", redirect_uri.to_owned()),
214 ];
215 if let Some(verifier) = pkce_verifier {
216 form.push(("code_verifier", verifier.to_owned()));
217 }
218 self.token_call(form).await
219 }
220
221 pub async fn refresh(&self, refresh_token: &str) -> Result<TokenResponse, OAuthError> {
231 self.token_call(vec![
232 ("grant_type", "refresh_token".to_owned()),
233 ("refresh_token", refresh_token.to_owned()),
234 ])
235 .await
236 }
237
238 pub async fn revoke(&self, token: &str, token_type_hint: &str) -> Result<(), OAuthError> {
249 let Some(url) = self.config.revoke_url.as_deref() else {
250 return Err(OAuthError::NoRevokeEndpoint);
251 };
252 let form = self.form(vec![
253 ("token", token.to_owned()),
254 ("token_type_hint", token_type_hint.to_owned()),
255 ])?;
256 let mut builder = Request::builder()
257 .method(Method::POST)
258 .uri(url)
259 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
260 if let Some(basic) = self.basic_header()? {
261 builder = builder.header(header::AUTHORIZATION, basic);
262 }
263 let request = builder
264 .body(Bytes::from(form))
265 .map_err(|err| OAuthError::Transport(HttpError::Transport(err.to_string())))?;
266 let response = self.http.send(request).await?;
267 if response.status().is_success() {
268 return Ok(());
269 }
270 Err(provider_error(&response))
271 }
272
273 fn form(&self, mut params: Vec<(&str, String)>) -> Result<String, OAuthError> {
280 if self.config.client_auth == ClientAuth::ClientSecretPost {
281 params.push(("client_id", self.config.client_id.clone()));
282 params.push(("client_secret", self.secret()?.to_owned()));
283 }
284 Ok(params
285 .iter()
286 .map(|(name, value)| format!("{name}={}", form_encode(value)))
287 .collect::<Vec<_>>()
288 .join("&"))
289 }
290
291 fn basic_header(&self) -> Result<Option<String>, OAuthError> {
294 if self.config.client_auth != ClientAuth::ClientSecretBasic {
295 return Ok(None);
296 }
297 let credentials = format!(
300 "{}:{}",
301 form_encode(&self.config.client_id),
302 form_encode(self.secret()?)
303 );
304 Ok(Some(format!(
305 "Basic {}",
306 base64::engine::general_purpose::STANDARD.encode(credentials)
307 )))
308 }
309
310 fn secret(&self) -> Result<&str, OAuthError> {
311 self.config.client_secret.as_deref().ok_or_else(|| {
312 OAuthError::Config("the configured client auth needs a client_secret".to_owned())
313 })
314 }
315
316 async fn token_call(&self, form: Vec<(&str, String)>) -> Result<TokenResponse, OAuthError> {
317 let body = self.form(form)?;
318 let mut request = Request::builder()
319 .method(Method::POST)
320 .uri(&self.config.token_url)
321 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
322 .header(header::ACCEPT, "application/json");
323 if let Some(basic) = self.basic_header()? {
324 request = request.header(header::AUTHORIZATION, basic);
325 }
326 let request = request
327 .body(Bytes::from(body))
328 .map_err(|err| OAuthError::Transport(HttpError::Transport(err.to_string())))?;
329 let response = self.http.send(request).await?;
330 if !response.status().is_success() {
331 return Err(provider_error(&response));
332 }
333 serde_json::from_slice(response.body())
334 .map_err(|err| OAuthError::Decode(format!("token response did not parse: {err}")))
335 }
336}
337
338fn provider_error(response: &http::Response<Bytes>) -> OAuthError {
341 let status = response.status();
342 let raw = String::from_utf8_lossy(response.body()).into_owned();
343 let parsed = serde_json::from_slice::<ErrorBody>(response.body()).ok();
344 let code = parsed
345 .as_ref()
346 .and_then(|body| body.error.clone())
347 .unwrap_or_default();
348 let description = parsed
349 .and_then(|body| body.error_description)
350 .unwrap_or_default();
351 OAuthError::from(Box::new(ProviderError {
352 status: status.as_u16(),
353 code,
354 description,
355 body: raw,
356 headers: response.headers().clone(),
357 }))
358}
359
360#[derive(Deserialize)]
361struct ErrorBody {
362 error: Option<String>,
363 error_description: Option<String>,
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369 use async_trait::async_trait;
370 use std::sync::RwLock;
371
372 const TOKENS: &str = r#"{"access_token":"at-critical-do-not-log","token_type":"Bearer",
373 "expires_in":3600,"refresh_token":"rt-critical-do-not-log",
374 "refresh_token_expires_in":5184000,"scope":"w_member_social"}"#;
375
376 struct Scripted {
379 status: u16,
380 body: &'static str,
381 seen: RwLock<Vec<Request<Bytes>>>,
382 }
383
384 impl Scripted {
385 fn ok() -> Self {
386 Self {
387 status: 200,
388 body: TOKENS,
389 seen: RwLock::new(Vec::new()),
390 }
391 }
392
393 fn with(status: u16, body: &'static str) -> Self {
394 Self {
395 status,
396 body,
397 seen: RwLock::new(Vec::new()),
398 }
399 }
400
401 fn form_of(&self, index: usize) -> String {
404 let seen = self.seen.read().expect("lock");
405 String::from_utf8_lossy(seen[index].body()).into_owned()
406 }
407
408 fn header_of(&self, index: usize, name: &str) -> Option<String> {
409 let seen = self.seen.read().expect("lock");
410 seen[index]
411 .headers()
412 .get(name)
413 .and_then(|value| value.to_str().ok())
414 .map(str::to_owned)
415 }
416 }
417
418 #[async_trait]
419 impl HttpClient for Scripted {
420 async fn send(&self, request: Request<Bytes>) -> Result<http::Response<Bytes>, HttpError> {
421 self.seen.write().expect("lock").push(request);
422 Ok(http::Response::builder()
423 .status(self.status)
424 .body(Bytes::from(self.body))
425 .expect("static body"))
426 }
427 }
428
429 fn post_config() -> ProviderConfig {
430 ProviderConfig {
431 authorize_url: "https://provider.example/oauth/authorize".to_owned(),
432 token_url: "https://provider.example/oauth/token".to_owned(),
433 revoke_url: Some("https://provider.example/oauth/revoke".to_owned()),
434 client_id: "client".to_owned(),
435 client_secret: Some("hunter2-do-not-log".to_owned()),
436 scopes: Vec::new(),
437 scope_separator: " ",
438 client_auth: ClientAuth::ClientSecretPost,
439 }
440 }
441
442 fn basic_config() -> ProviderConfig {
443 ProviderConfig {
444 client_auth: ClientAuth::ClientSecretBasic,
445 ..post_config()
446 }
447 }
448
449 #[pollster::test]
450 async fn exchange_sends_the_grant_with_the_credentials_in_the_body() {
451 let http = Scripted::ok();
452 let tokens = OAuthClient::new(&http, &post_config())
453 .exchange_code("the-code", "https://api.test/cb", Some("the-verifier"))
454 .await
455 .expect("the exchange succeeds");
456 assert_eq!(tokens.access_token, "at-critical-do-not-log");
457 assert_eq!(tokens.expires_in, 3600);
458 assert_eq!(tokens.refresh_token_expires_in, Some(5_184_000));
459 assert_eq!(
460 tokens.rotated_refresh_token("older"),
461 "rt-critical-do-not-log"
462 );
463 let form = http.form_of(0);
464 for piece in [
465 "grant_type=authorization_code",
466 "code=the-code",
467 "redirect_uri=https%3A%2F%2Fapi.test%2Fcb",
468 "code_verifier=the-verifier",
469 "client_id=client",
470 "client_secret=hunter2-do-not-log",
471 ] {
472 assert!(form.contains(piece), "{piece:?} missing from {form}");
473 }
474 assert_eq!(
475 http.header_of(0, "authorization"),
476 None,
477 "Post auth: no header"
478 );
479 }
480
481 #[pollster::test]
484 async fn basic_auth_keeps_the_client_id_out_of_the_body() {
485 let http = Scripted::ok();
486 OAuthClient::new(&http, &basic_config())
487 .refresh("rt-being-rotated")
488 .await
489 .expect("the refresh succeeds");
490 let form = http.form_of(0);
491 assert!(form.contains("grant_type=refresh_token"), "{form}");
492 assert!(form.contains("refresh_token=rt-being-rotated"), "{form}");
493 assert!(!form.contains("client_id"), "{form}");
494 assert!(!form.contains("client_secret"), "{form}");
495 let authorization = http.header_of(0, "authorization").expect("Basic header");
496 assert_eq!(
499 authorization, "Basic Y2xpZW50Omh1bnRlcjItZG8tbm90LWxvZw==",
500 "form-encoded credentials, standard base64"
501 );
502 }
503
504 #[pollster::test]
507 async fn quoted_expiries_parse_like_numbers() {
508 let http = Scripted::with(
509 200,
510 r#"{"access_token":"at","expires_in":"3600",
511 "refresh_token_expires_in":null,"scope":"s"}"#,
512 );
513 let tokens = OAuthClient::new(&http, &post_config())
514 .refresh("rt")
515 .await
516 .expect("parses");
517 assert_eq!(tokens.expires_in, 3600);
518 assert_eq!(tokens.refresh_token_expires_in, None);
519 assert_eq!(tokens.rotated_refresh_token("kept"), "kept");
520 }
521
522 #[pollster::test]
525 async fn a_provider_error_keeps_the_prose_and_hides_the_body() {
526 let http = Scripted::with(
527 400,
528 r#"{"error":"invalid_grant","error_description":"Refresh token rt-critical-do-not-log expired"}"#,
529 );
530 let error = OAuthClient::new(&http, &post_config())
531 .refresh("rt-critical-do-not-log")
532 .await
533 .expect_err("the provider refused");
534 assert!(error.is_invalid_grant(), "{error}");
535 assert!(
536 error
537 .to_string()
538 .contains("Refresh token rt-critical-do-not-log expired"),
539 "{error}"
540 );
541 let debug = format!("{error:?}");
542 assert!(debug.contains("invalid_grant"), "{debug}");
543 assert!(
544 !debug.contains("\"error_description\""),
545 "raw body in the debug: {debug}"
546 );
547 assert!(debug.contains("<redacted 92 bytes>"), "{debug}");
548
549 let http = Scripted::with(400, "not json at all");
550 let error = OAuthClient::new(&http, &post_config())
551 .refresh("rt")
552 .await
553 .expect_err("a non-JSON refusal is still a provider error");
554 assert!(
555 error.to_string().starts_with("provider answered 400"),
556 "{error}"
557 );
558 assert!(matches!(error, OAuthError::Provider(_)), "{error:?}");
559 }
560
561 #[pollster::test]
562 async fn a_success_that_is_not_json_is_a_decode_error() {
563 let http = Scripted::with(200, "<html>ok</html>");
564 let error = OAuthClient::new(&http, &post_config())
565 .refresh("rt")
566 .await
567 .expect_err("html is not a token response");
568 assert!(matches!(error, OAuthError::Decode(_)), "{error}");
569 }
570
571 #[pollster::test]
572 async fn the_debug_of_a_token_response_never_shows_the_tokens() {
573 let http = Scripted::ok();
574 let tokens = OAuthClient::new(&http, &post_config())
575 .refresh("rt")
576 .await
577 .expect("parses");
578 let debug = format!("{tokens:?}");
579 assert!(!debug.contains("at-critical-do-not-log"), "{debug}");
580 assert!(!debug.contains("rt-critical-do-not-log"), "{debug}");
581 assert_eq!(debug.matches("<redacted>").count(), 2, "{debug}");
582 }
583
584 #[pollster::test]
585 async fn revocation_refuses_to_guess_an_endpoint() {
586 let http = Scripted::ok();
587 let config = ProviderConfig {
588 revoke_url: None,
589 ..post_config()
590 };
591 let error = OAuthClient::new(&http, &config)
592 .revoke("rt", "refresh_token")
593 .await
594 .expect_err("no revoke endpoint configured");
595 assert!(matches!(error, OAuthError::NoRevokeEndpoint), "{error}");
596 }
597}