Skip to main content

cratefield_oauth_client/
client.rs

1//! The token endpoint: exchange, refresh, revoke.
2
3use 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/// What went wrong with an OAuth call, in the shape a caller branches on.
13#[derive(Debug, Error)]
14pub enum OAuthError {
15    /// The network, or a port failure (cap, deadline, refused destination).
16    #[error("transport: {0}")]
17    Transport(#[from] HttpError),
18    /// The provider answered with an error; see [`ProviderError`] for what
19    /// it carries.
20    #[error(transparent)]
21    Provider(#[from] Box<ProviderError>),
22    /// A success whose body was not the JSON the contract says.
23    #[error("unexpected token response: {0}")]
24    Decode(String),
25    /// The caller's own configuration is incomplete: a client auth method
26    /// that needs a secret was configured without one.
27    #[error("oauth client is misconfigured: {0}")]
28    Config(String),
29    /// [`OAuthClient::revoke`](crate::OAuthClient::revoke) was called on a
30    /// [`ProviderConfig`] that names no revocation endpoint.
31    #[error("no revocation endpoint is configured for this provider")]
32    NoRevokeEndpoint,
33}
34
35impl OAuthError {
36    /// RFC 6749 §5.2's `invalid_grant`: the authorization code or refresh
37    /// token is invalid, expired or revoked. The one code that means
38    /// "the human must authorize again" and not "try again later".
39    #[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/// The provider's answer when a token call fails. `code` and `description`
46/// are the RFC 6749 §5.2 `error` / `error_description` pair when the body
47/// was JSON; `body` is the raw response either way, because providers put
48/// provider-specific meaning in that prose — LinkedIn names a dead refresh
49/// token only in the description, never in the code.
50#[derive(Error)]
51#[error("provider answered {status} ({code}): {description}")]
52pub struct ProviderError {
53    /// HTTP status the provider answered with.
54    pub status: u16,
55    /// The RFC 6749 §5.2 `error` code, empty when the body was not JSON.
56    pub code: String,
57    /// The `error_description`, empty when the body was not JSON.
58    pub description: String,
59    /// The response body, verbatim.
60    pub body: String,
61    /// The response headers — `Retry-After` on a 429, provider trace ids.
62    pub headers: HeaderMap,
63}
64
65impl std::fmt::Debug for ProviderError {
66    /// Errors end up in logs, and a provider error body can echo the very
67    /// token that was refused — so the body is sized, never printed.
68    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/// The token endpoint's answer (RFC 6749 §5.1), with the fields providers
83/// actually add. Everything but the access token is optional or defaulted:
84/// providers omit what does not apply, and a missing `expires_in` must not
85/// become an error the caller has to defend against.
86#[derive(Clone, Deserialize)]
87pub struct TokenResponse {
88    /// The bearer credential every provider sends.
89    pub access_token: String,
90    /// Usually `Bearer`; the RFC says treat it as case-insensitive advice.
91    pub token_type: Option<String>,
92    /// Access-token lifetime in seconds. `0` when the provider omits it.
93    #[serde(default, deserialize_with = "de_expiry")]
94    pub expires_in: i64,
95    /// May be absent on a refresh: some providers rotate, some do not.
96    pub refresh_token: Option<String>,
97    /// The refresh token's own lifetime, when a provider gives one — and
98    /// when it does, it does not extend on use, so store it, never recompute.
99    #[serde(default, deserialize_with = "de_expiry_opt")]
100    pub refresh_token_expires_in: Option<i64>,
101    /// The scopes granted, which may be fewer than those requested.
102    #[serde(default)]
103    pub scope: String,
104}
105
106impl std::fmt::Debug for TokenResponse {
107    /// Token responses are logged by everything that touches them; both
108    /// secrets are named, never printed.
109    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
124/// The seconds fields, `expires_in` and `refresh_token_expires_in`: a JSON
125/// number per RFC 6749 §5.1, but providers quote them, so a numeric string
126/// is accepted too. `null` counts as absent, like a field left out.
127fn 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    /// The refresh token now in force after a refresh: the one this response
171    /// carries, or `previous` when the provider rotated nothing. Call it
172    /// before storing, so a provider that omits the field cannot be read as
173    /// "delete the refresh token".
174    #[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
180/// Speaks one provider's token endpoint over an [`HttpClient`]. Cheap to
181/// build, holds nothing but the two references it is given.
182pub struct OAuthClient<'a> {
183    http: &'a dyn HttpClient,
184    config: &'a ProviderConfig,
185}
186
187impl<'a> OAuthClient<'a> {
188    /// A client for `config`, sending through `http`.
189    #[must_use]
190    pub fn new(http: &'a dyn HttpClient, config: &'a ProviderConfig) -> Self {
191        Self { http, config }
192    }
193
194    /// The second leg of the authorization-code flow (RFC 6749 §4.1.3):
195    /// the code from the callback for an access token. `pkce_verifier` is
196    /// the [`Pkce::verifier`](crate::Pkce::verifier) behind the challenge
197    /// that went on the authorize URL, when PKCE is in play.
198    ///
199    /// # Errors
200    ///
201    /// Transport failures, the provider's own refusal (most often
202    /// `invalid_grant` for a spent or forged code), an unparseable success
203    /// body, or a configuration missing its secret.
204    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    /// Exchanges a refresh token for a fresh access token (RFC 6749 §6).
222    /// Check [`TokenResponse::rotated_refresh_token`] afterwards: a response
223    /// without a refresh token means keep the old one, not discard it.
224    ///
225    /// # Errors
226    ///
227    /// Transport failures, the provider's own refusal (most often
228    /// `invalid_grant` for a dead refresh token), an unparseable success
229    /// body, or a configuration missing its secret.
230    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    /// Revokes a token (RFC 7009); `token_type_hint` is `access_token` or
239    /// `refresh_token` where the provider documents hints. Success is a 2xx,
240    /// and per the RFC that is a 200 even for an already-revoked token:
241    /// revocation is idempotent by design.
242    ///
243    /// # Errors
244    ///
245    /// Returns [`OAuthError::NoRevokeEndpoint`] when the [`ProviderConfig`]
246    /// names no revocation endpoint, and the usual transport / provider /
247    /// decode errors otherwise.
248    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    /// The form body for a token-endpoint call, with the credentials where
274    /// [`ClientAuth`] says they go — and in one place only: RFC 6749 §2.3.1
275    /// names one method per call, so under Basic the client id rides in the
276    /// `Authorization` header and the body goes without it. The secret is
277    /// added here, not by the callers, so no grant can forget it and no
278    /// caller can log the form with the secret missing.
279    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    /// The `Authorization` header value for Basic client auth, or `None`
292    /// when the provider takes its credentials in the form body instead.
293    fn basic_header(&self) -> Result<Option<String>, OAuthError> {
294        if self.config.client_auth != ClientAuth::ClientSecretBasic {
295            return Ok(None);
296        }
297        // RFC 6749 §2.3.1: both halves are form-urlencoded before the
298        // colon, and the join is standard base64.
299        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
338/// Reads a failure response into [`ProviderError`]: the §5.2 pair when the
339/// body is JSON, the raw body and the headers either way.
340fn 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    /// Answers every send with one canned response and keeps the requests,
377    /// so the assertions can read the form and headers a call actually sent.
378    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        /// The client id must appear in the body only under Post auth, so
402        /// both the positive and the negative assertion read the same way.
403        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    /// RFC 6749 §2.3.1: one method per call. Under Basic the client id rides
482    /// in the Authorization header, so the body must not repeat it.
483    #[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        // RFC 6749 §2.3.1: both halves form-encoded before the colon
497        // (`client:hunter2-do-not-log`, standard base64).
498        assert_eq!(
499            authorization, "Basic Y2xpZW50Omh1bnRlcjItZG8tbm90LWxvZw==",
500            "form-encoded credentials, standard base64"
501        );
502    }
503
504    /// Providers quote the seconds fields; a number and a numeric string
505    /// must land as the same value, and `null` as absent.
506    #[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    /// The raw body can echo the refused token; the error's Display keeps
523    /// the prose the caller needs, its Debug must not carry the body.
524    #[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}