Skip to main content

hey_sdk/
oauth.rs

1use std::fmt;
2use std::sync::Arc;
3
4use base64::Engine;
5use base64::engine::general_purpose::URL_SAFE_NO_PAD;
6use bytes::{Bytes, BytesMut};
7use chrono::{DateTime, TimeDelta, Utc};
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10use url::{Url, form_urlencoded};
11
12use crate::error::{Error, MAX_ERROR_MESSAGE_BYTES, truncate};
13use crate::http::header::{ACCEPT, CONTENT_TYPE};
14use crate::http::{Body, HttpClient, Method, Request, Response, StatusCode};
15use crate::security::require_secure_endpoint;
16use crate::types::SensitiveString;
17
18const FORM_CONTENT_TYPE: &str = "application/x-www-form-urlencoded";
19const MAX_DISCOVERY_ERROR_BYTES: usize = 4096;
20const MAX_TOKEN_RESPONSE_BYTES: usize = 1 << 20;
21
22/// What an OAuth 2.0 server publishes about itself at its well-known endpoint.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct ServerMetadata {
25    /// Who issues the tokens, as the server names itself.
26    pub issuer: String,
27    /// Where to send someone to approve the client.
28    pub authorization_endpoint: String,
29    /// Where codes and refresh tokens are traded for tokens.
30    pub token_endpoint: String,
31    /// Where a client registers itself, on a server that lets it.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub registration_endpoint: Option<String>,
34    /// The scopes the server knows, when it lists them.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub scopes_supported: Option<Vec<String>>,
37}
38
39impl ServerMetadata {
40    /// HEY's endpoints under `base_url`. HEY publishes no well-known document, so
41    /// [`OAuthClient::discover`] has nothing to find there; this is where hey-cli sends
42    /// people and tokens.
43    pub fn for_hey(base_url: &str) -> ServerMetadata {
44        let origin = base_url.trim_end_matches('/');
45        ServerMetadata {
46            issuer: origin.to_string(),
47            authorization_endpoint: format!("{origin}/oauth/authorizations/new"),
48            token_endpoint: format!("{origin}/oauth/tokens"),
49            registration_endpoint: None,
50            scopes_supported: None,
51        }
52    }
53}
54
55/// A token response. `expires_in` is a lifetime that starts decaying the moment the server
56/// answers, so `expires_at` is worked out from it on arrival and is the field to keep: a
57/// moment stays true however long it is held. The server never sends it, so it defaults to
58/// `None` when it is missing and is written back out with the rest.
59///
60/// The SDK stores nothing itself. Whoever keeps a token between runs stores this or their
61/// own shape, and either way carries `expires_at` with it, or the next run has no idea
62/// when the token is spent.
63///
64/// The two tokens are [`SensitiveString`]s: serde-transparent, so the wire is what the
65/// server sent, but `[REDACTED]` under `{:?}`.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[non_exhaustive]
68pub struct Token {
69    /// What goes in `Authorization` on every request.
70    pub access_token: SensitiveString,
71    /// What buys the next access token once this one is spent, when the server issued one.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub refresh_token: Option<SensitiveString>,
74    /// How the access token is presented: `Bearer`, for HEY.
75    #[serde(default)]
76    pub token_type: String,
77    /// How many seconds the access token was good for when the server answered.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub expires_in: Option<u64>,
80    /// What the token was granted, when the server said.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub scope: Option<String>,
83    /// The moment the access token is spent, worked out from `expires_in` on arrival.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub expires_at: Option<DateTime<Utc>>,
86}
87
88/// A code verifier and the challenge derived from it, for the S256 PKCE flow. The verifier
89/// is the secret half — whoever holds it can redeem the code — so it prints as
90/// `[REDACTED]`; the challenge goes out in the authorization URL and is public.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct Pkce {
93    /// The secret half, redeemed with the code at the token endpoint.
94    pub verifier: SensitiveString,
95    /// The public half, sent in the authorization URL.
96    pub challenge: String,
97}
98
99/// Draws a fresh verifier from the system CSPRNG and derives its SHA-256 challenge.
100pub fn generate_pkce() -> Pkce {
101    let verifier = URL_SAFE_NO_PAD.encode(rand::random::<[u8; 32]>());
102    let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
103    Pkce {
104        verifier: SensitiveString::new(verifier),
105        challenge,
106    }
107}
108
109/// Draws a fresh `state` parameter from the system CSPRNG.
110pub fn generate_state() -> String {
111    URL_SAFE_NO_PAD.encode(rand::random::<[u8; 16]>())
112}
113
114/// The URL to send someone to so they can approve the client.
115///
116/// HEY's authorization endpoint is not quite RFC 6749: it dispatches on `grant_type`
117/// rather than `response_type`, and it wants the `install_id` that identifies this
118/// installation as a device, on this request and on every token request after it. The
119/// Go SDK sends neither and cannot complete a login against HEY; this is the URL hey-cli
120/// sends, which can.
121pub fn authorization_url(
122    metadata: &ServerMetadata,
123    client_id: &str,
124    redirect_uri: &str,
125    scope: Option<&str>,
126    state: &str,
127    pkce: &Pkce,
128    install_id: &str,
129) -> Result<Url, Error> {
130    let mut url = Url::parse(&metadata.authorization_endpoint)?;
131    {
132        let mut query = url.query_pairs_mut();
133        query.append_pair("client_id", client_id);
134        query.append_pair("grant_type", "authorization_code");
135        query.append_pair("redirect_uri", redirect_uri);
136        if let Some(scope) = scope {
137            query.append_pair("scope", scope);
138        }
139        query.append_pair("state", state);
140        query.append_pair("code_challenge", &pkce.challenge);
141        query.append_pair("code_challenge_method", "S256");
142        query.append_pair("install_id", install_id);
143    }
144    Ok(url)
145}
146
147/// Trades an authorization code for tokens. The code and the client secret are the two
148/// halves worth stealing, so both print as `[REDACTED]`.
149#[derive(Debug, Clone, Default)]
150pub struct ExchangeRequest {
151    /// Where the code is traded: [`ServerMetadata::token_endpoint`].
152    pub token_endpoint: String,
153    /// The authorization code the redirect carried back.
154    pub code: SensitiveString,
155    /// The redirect URI the authorization request named; the server checks they match.
156    pub redirect_uri: String,
157    /// The client the code was issued to.
158    pub client_id: String,
159    /// The client's secret, for a client that was issued one.
160    pub client_secret: Option<SensitiveString>,
161    /// The verifier [`generate_pkce`] drew, which redeems the code and so is a secret of the
162    /// same weight.
163    pub code_verifier: SensitiveString,
164    /// The same installation identifier the authorization URL carried. HEY refuses the
165    /// exchange without it.
166    pub install_id: String,
167}
168
169/// Trades a refresh token for a new access token.
170#[derive(Debug, Clone, Default)]
171pub struct RefreshRequest {
172    /// Where the refresh token is traded: [`ServerMetadata::token_endpoint`].
173    pub token_endpoint: String,
174    /// The refresh token the last [`Token`] carried.
175    pub refresh_token: SensitiveString,
176    /// The client the tokens were issued to. Left empty, it is not sent.
177    pub client_id: String,
178    /// The client's secret, for a client that was issued one.
179    pub client_secret: Option<SensitiveString>,
180    /// The installation identifier the tokens were issued to. HEY refuses the refresh
181    /// without it.
182    pub install_id: String,
183}
184
185/// Talks to an OAuth 2.0 server: discovery, code exchange and refresh.
186///
187/// It sends on any [`HttpClient`]. With the `reqwest` feature, [`Default`] builds it over
188/// the one the SDK ships, so an application need not depend on `reqwest` itself;
189/// [`new`](OAuthClient::new) is for one that has a client of its own to share.
190#[derive(Clone)]
191pub struct OAuthClient {
192    http: Arc<dyn HttpClient>,
193}
194
195impl OAuthClient {
196    /// A client that sends on `http`.
197    pub fn new(http: impl HttpClient + 'static) -> OAuthClient {
198        OAuthClient {
199            http: Arc::new(http),
200        }
201    }
202
203    /// What the server under `base_url` publishes at its well-known endpoint. HEY publishes
204    /// nothing there; [`ServerMetadata::for_hey`] is its answer.
205    pub async fn discover(&self, base_url: &str) -> Result<ServerMetadata, Error> {
206        let url = format!(
207            "{}/.well-known/oauth-authorization-server",
208            base_url.trim_end_matches('/')
209        );
210        let request = Request::builder()
211            .method(Method::GET)
212            .uri(url)
213            .header(ACCEPT, "application/json")
214            .body(Bytes::new())
215            .map_err(Error::from_std)?;
216        let response = self.http.send(request).await?;
217
218        let status = response.status();
219        if status == StatusCode::OK {
220            let body = read_capped(response, MAX_TOKEN_RESPONSE_BYTES).await?;
221            Ok(serde_json::from_slice(&body)?)
222        } else {
223            let body = read_truncated(response, MAX_DISCOVERY_ERROR_BYTES).await;
224            Err(Error::api(
225                status.as_u16(),
226                format!("OAuth discovery failed with status {status}"),
227            )
228            .with_hint(body))
229        }
230    }
231
232    /// Trades the code in `request` for a [`Token`]. A request missing anything HEY needs
233    /// is refused before it is sent.
234    pub async fn exchange(&self, request: &ExchangeRequest) -> Result<Token, Error> {
235        require(&request.token_endpoint, "token endpoint is required")?;
236        require(request.code.expose(), "authorization code is required")?;
237        require(&request.redirect_uri, "redirect URI is required")?;
238        require(&request.client_id, "client ID is required")?;
239        require(&request.install_id, "install ID is required")?;
240
241        self.post_token_request(&request.token_endpoint, exchange_form(request))
242            .await
243    }
244
245    /// Trades the refresh token in `request` for a new [`Token`]. A request missing anything
246    /// HEY needs is refused before it is sent.
247    pub async fn refresh(&self, request: &RefreshRequest) -> Result<Token, Error> {
248        require(&request.token_endpoint, "token endpoint is required")?;
249        require(request.refresh_token.expose(), "refresh token is required")?;
250        require(&request.install_id, "install ID is required")?;
251
252        self.post_token_request(&request.token_endpoint, refresh_form(request))
253            .await
254    }
255
256    async fn post_token_request(&self, token_endpoint: &str, form: String) -> Result<Token, Error> {
257        let url = Url::parse(token_endpoint)?;
258        require_secure_endpoint(&url)?;
259
260        let request = Request::builder()
261            .method(Method::POST)
262            .uri(url.as_str())
263            .header(ACCEPT, "application/json")
264            .header(CONTENT_TYPE, FORM_CONTENT_TYPE)
265            .body(Bytes::from(form))
266            .map_err(Error::from_std)?;
267        let response = self.http.send(request).await?;
268
269        let status = response.status();
270        let body = read_capped(response, MAX_TOKEN_RESPONSE_BYTES).await?;
271        if status == StatusCode::OK {
272            let mut token: Token = serde_json::from_slice(&body)?;
273            token.expires_at = token.expires_in.and_then(expires_at);
274            Ok(token)
275        } else {
276            Err(token_error(status, &body))
277        }
278    }
279}
280
281fn require(value: &str, message: &str) -> Result<(), Error> {
282    if value.is_empty() {
283        Err(Error::usage(message))
284    } else {
285        Ok(())
286    }
287}
288
289fn exchange_form(request: &ExchangeRequest) -> String {
290    let mut form = form_urlencoded::Serializer::new(String::new());
291    form.append_pair("grant_type", "authorization_code");
292    form.append_pair("code", request.code.expose());
293    form.append_pair("redirect_uri", &request.redirect_uri);
294    form.append_pair("client_id", &request.client_id);
295    if let Some(secret) = &request.client_secret {
296        form.append_pair("client_secret", secret.expose());
297    }
298    if !request.code_verifier.is_empty() {
299        form.append_pair("code_verifier", request.code_verifier.expose());
300    }
301    form.append_pair("install_id", &request.install_id);
302    form.finish()
303}
304
305fn refresh_form(request: &RefreshRequest) -> String {
306    let mut form = form_urlencoded::Serializer::new(String::new());
307    form.append_pair("grant_type", "refresh_token");
308    form.append_pair("refresh_token", request.refresh_token.expose());
309    if !request.client_id.is_empty() {
310        form.append_pair("client_id", &request.client_id);
311    }
312    if let Some(secret) = &request.client_secret {
313        form.append_pair("client_secret", secret.expose());
314    }
315    form.append_pair("install_id", &request.install_id);
316    form.finish()
317}
318
319impl fmt::Debug for OAuthClient {
320    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
321        f.debug_struct("OAuthClient").finish_non_exhaustive()
322    }
323}
324
325#[cfg(feature = "reqwest")]
326#[cfg_attr(docsrs, doc(cfg(feature = "reqwest")))]
327impl Default for OAuthClient {
328    fn default() -> OAuthClient {
329        OAuthClient::new(crate::http::ReqwestClient::default())
330    }
331}
332
333async fn read_capped(response: Response<Body>, limit: usize) -> Result<Bytes, Error> {
334    response
335        .into_body()
336        .collect(limit, || {
337            Error::api(0, format!("OAuth response body exceeds {limit} bytes"))
338        })
339        .await
340}
341
342/// Reads up to `limit` bytes for an error message, giving up on whatever it has when the
343/// body itself fails to arrive.
344async fn read_truncated(response: Response<Body>, limit: usize) -> String {
345    let mut stream = response.into_body();
346    let mut body = BytesMut::new();
347    while body.len() < limit {
348        match stream.chunk().await {
349            Ok(Some(chunk)) => body.extend_from_slice(&chunk),
350            Ok(None) | Err(_) => break,
351        }
352    }
353    body.truncate(limit);
354    String::from_utf8_lossy(&body).into_owned()
355}
356
357fn expires_at(seconds: u64) -> Option<DateTime<Utc>> {
358    let lifetime = TimeDelta::try_seconds(i64::try_from(seconds).ok()?)?;
359    Utc::now().checked_add_signed(lifetime)
360}
361
362fn token_error(status: StatusCode, body: &[u8]) -> Error {
363    match serde_json::from_slice::<TokenErrorResponse>(body) {
364        Ok(response) if !response.error.is_empty() => {
365            let description = response.error_description.unwrap_or_default();
366            if description.is_empty() {
367                Error::auth(format!("token error: {}", response.error)).with_status(status.as_u16())
368            } else {
369                let description = truncate(&description, MAX_ERROR_MESSAGE_BYTES);
370                Error::auth(format!("token error: {}", response.error))
371                    .with_hint(description)
372                    .with_status(status.as_u16())
373            }
374        }
375        _ => {
376            let body = truncate(&String::from_utf8_lossy(body), MAX_ERROR_MESSAGE_BYTES);
377            Error::auth(format!("token request failed with status {status}"))
378                .with_hint(body)
379                .with_status(status.as_u16())
380        }
381    }
382}
383
384#[derive(Deserialize)]
385struct TokenErrorResponse {
386    #[serde(default)]
387    error: String,
388    #[serde(default)]
389    error_description: Option<String>,
390}
391
392#[cfg(test)]
393mod tests {
394    use serde_json::json;
395    #[cfg(feature = "reqwest")]
396    use wiremock::matchers::{body_string_contains, header, method, path};
397    #[cfg(feature = "reqwest")]
398    use wiremock::{Mock, MockServer, ResponseTemplate};
399
400    use super::*;
401
402    #[test]
403    fn pkce_challenge_is_the_digest_of_the_verifier() {
404        let pkce = generate_pkce();
405
406        assert_eq!(
407            32,
408            URL_SAFE_NO_PAD
409                .decode(pkce.verifier.expose())
410                .unwrap()
411                .len()
412        );
413        assert_eq!(
414            URL_SAFE_NO_PAD.encode(Sha256::digest(pkce.verifier.expose().as_bytes())),
415            pkce.challenge
416        );
417        assert_ne!(pkce.verifier, generate_pkce().verifier);
418        assert_eq!("[REDACTED]", format!("{:?}", pkce.verifier));
419    }
420
421    #[test]
422    fn state_is_sixteen_random_bytes() {
423        let state = generate_state();
424
425        assert_eq!(16, URL_SAFE_NO_PAD.decode(&state).unwrap().len());
426        assert_ne!(state, generate_state());
427    }
428
429    #[test]
430    fn authorization_url_carries_the_challenge_state_and_install_id() {
431        let pkce = generate_pkce();
432        let url = authorization_url(
433            &metadata("https://auth.example.com"),
434            "client-1",
435            "http://127.0.0.1:9000/callback",
436            Some("read write"),
437            "state-1",
438            &pkce,
439            "install-1",
440        )
441        .unwrap();
442
443        let query: Vec<(String, String)> = url
444            .query_pairs()
445            .map(|(name, value)| (name.into_owned(), value.into_owned()))
446            .collect();
447        assert_eq!("https", url.scheme());
448        assert_eq!("/authorize", url.path());
449        assert_eq!(
450            vec![
451                ("client_id".to_string(), "client-1".to_string()),
452                ("grant_type".to_string(), "authorization_code".to_string()),
453                (
454                    "redirect_uri".to_string(),
455                    "http://127.0.0.1:9000/callback".to_string()
456                ),
457                ("scope".to_string(), "read write".to_string()),
458                ("state".to_string(), "state-1".to_string()),
459                ("code_challenge".to_string(), pkce.challenge.clone()),
460                ("code_challenge_method".to_string(), "S256".to_string()),
461                ("install_id".to_string(), "install-1".to_string()),
462            ],
463            query
464        );
465    }
466
467    #[test]
468    fn hey_metadata_points_at_its_oauth_routes() {
469        let metadata = ServerMetadata::for_hey("https://app.hey.com/");
470
471        assert_eq!("https://app.hey.com", metadata.issuer);
472        assert_eq!(
473            "https://app.hey.com/oauth/authorizations/new",
474            metadata.authorization_endpoint
475        );
476        assert_eq!("https://app.hey.com/oauth/tokens", metadata.token_endpoint);
477    }
478
479    #[test]
480    fn authorization_url_leaves_out_an_absent_scope() {
481        let url = authorization_url(
482            &metadata("https://auth.example.com"),
483            "client-1",
484            "http://127.0.0.1:9000/callback",
485            None,
486            "state-1",
487            &generate_pkce(),
488            "install-1",
489        )
490        .unwrap();
491
492        assert!(!url.query().unwrap().contains("scope"));
493    }
494
495    #[cfg(feature = "reqwest")]
496    #[tokio::test]
497    async fn discover_reads_the_well_known_document() {
498        let server = MockServer::start().await;
499        Mock::given(method("GET"))
500            .and(path("/.well-known/oauth-authorization-server"))
501            .and(header("accept", "application/json"))
502            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
503                "issuer": "https://auth.example.com",
504                "authorization_endpoint": "https://auth.example.com/authorize",
505                "token_endpoint": "https://auth.example.com/token",
506                "scopes_supported": [ "read", "write" ]
507            })))
508            .mount(&server)
509            .await;
510
511        let metadata = OAuthClient::default()
512            .discover(&server.uri())
513            .await
514            .unwrap();
515
516        assert_eq!("https://auth.example.com/token", metadata.token_endpoint);
517        assert_eq!(
518            Some(vec!["read".to_string(), "write".to_string()]),
519            metadata.scopes_supported
520        );
521        assert_eq!(None, metadata.registration_endpoint);
522    }
523
524    #[cfg(feature = "reqwest")]
525    #[tokio::test]
526    async fn discover_reports_the_failing_status() {
527        let server = MockServer::start().await;
528        Mock::given(method("GET"))
529            .respond_with(ResponseTemplate::new(404).set_body_string("no such server"))
530            .mount(&server)
531            .await;
532
533        let error = OAuthClient::default()
534            .discover(&server.uri())
535            .await
536            .unwrap_err();
537
538        assert_eq!(Some(404), error.http_status());
539        assert_eq!(Some("no such server"), error.hint());
540    }
541
542    #[cfg(feature = "reqwest")]
543    #[tokio::test]
544    async fn exchange_trades_a_code_for_a_token() {
545        let server = MockServer::start().await;
546        Mock::given(method("POST"))
547            .and(path("/token"))
548            .and(header("content-type", FORM_CONTENT_TYPE))
549            .and(body_string_contains("grant_type=authorization_code"))
550            .and(body_string_contains("code_verifier=verifier-1"))
551            .and(body_string_contains("code=code-1"))
552            .and(body_string_contains("install_id=install-1"))
553            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
554                "access_token": "access-1",
555                "refresh_token": "refresh-1",
556                "token_type": "Bearer",
557                "expires_in": 3600,
558                "scope": "read"
559            })))
560            .mount(&server)
561            .await;
562
563        let request = ExchangeRequest {
564            token_endpoint: format!("{}/token", server.uri()),
565            code: "code-1".into(),
566            redirect_uri: "http://127.0.0.1:9000/callback".to_string(),
567            client_id: "client-1".to_string(),
568            client_secret: None,
569            code_verifier: "verifier-1".into(),
570            install_id: "install-1".to_string(),
571        };
572        let token = OAuthClient::default().exchange(&request).await.unwrap();
573
574        assert_eq!("access-1", token.access_token.expose());
575        assert_eq!(
576            Some("refresh-1"),
577            token.refresh_token.as_ref().map(SensitiveString::expose)
578        );
579        assert_eq!(Some(3600), token.expires_in);
580        assert!(token.expires_at.unwrap() > Utc::now());
581    }
582
583    #[test]
584    fn a_token_keeps_the_moment_it_expires_across_being_stored() {
585        let answered = serde_json::from_value::<Token>(json!({
586            "access_token": "access-1",
587            "token_type": "Bearer",
588            "expires_in": 3600
589        }))
590        .unwrap();
591        assert_eq!(None, answered.expires_at);
592
593        let mut token = answered;
594        token.expires_at = Utc::now().checked_add_signed(TimeDelta::seconds(3600));
595
596        let stored = serde_json::to_string(&token).unwrap();
597        let restored: Token = serde_json::from_str(&stored).unwrap();
598
599        assert_eq!(token, restored);
600        assert_eq!(token.expires_at, restored.expires_at);
601    }
602
603    #[cfg(feature = "reqwest")]
604    #[tokio::test]
605    async fn exchange_reports_the_server_error() {
606        let server = MockServer::start().await;
607        Mock::given(method("POST"))
608            .respond_with(ResponseTemplate::new(400).set_body_json(json!({
609                "error": "invalid_grant",
610                "error_description": "The authorization code has expired"
611            })))
612            .mount(&server)
613            .await;
614
615        let request = ExchangeRequest {
616            token_endpoint: format!("{}/token", server.uri()),
617            code: "code-1".into(),
618            redirect_uri: "http://127.0.0.1:9000/callback".to_string(),
619            client_id: "client-1".to_string(),
620            client_secret: None,
621            code_verifier: "verifier-1".into(),
622            install_id: "install-1".to_string(),
623        };
624        let error = OAuthClient::default().exchange(&request).await.unwrap_err();
625
626        assert_eq!(crate::error::ErrorCode::Auth, error.code());
627        assert_eq!("token error: invalid_grant", error.message());
628        assert_eq!(Some("The authorization code has expired"), error.hint());
629        assert_eq!(Some(400), error.http_status());
630    }
631
632    #[cfg(feature = "reqwest")]
633    #[tokio::test]
634    async fn exchange_wants_its_required_fields() {
635        let error = OAuthClient::default()
636            .exchange(&ExchangeRequest::default())
637            .await
638            .unwrap_err();
639
640        assert_eq!(crate::error::ErrorCode::Usage, error.code());
641        assert_eq!("token endpoint is required", error.message());
642    }
643
644    #[cfg(feature = "reqwest")]
645    #[tokio::test]
646    async fn exchange_wants_an_install_id() {
647        let request = ExchangeRequest {
648            token_endpoint: "https://auth.example.com/token".to_string(),
649            code: "code-1".into(),
650            redirect_uri: "http://127.0.0.1:9000/callback".to_string(),
651            client_id: "client-1".to_string(),
652            client_secret: None,
653            code_verifier: "verifier-1".into(),
654            install_id: String::new(),
655        };
656        let error = OAuthClient::default().exchange(&request).await.unwrap_err();
657
658        assert_eq!(crate::error::ErrorCode::Usage, error.code());
659        assert_eq!("install ID is required", error.message());
660    }
661
662    #[cfg(feature = "reqwest")]
663    #[tokio::test]
664    async fn refresh_trades_a_refresh_token_for_a_token() {
665        let server = MockServer::start().await;
666        Mock::given(method("POST"))
667            .and(path("/token"))
668            .and(body_string_contains("grant_type=refresh_token"))
669            .and(body_string_contains("refresh_token=refresh-1"))
670            .and(body_string_contains("client_id=client-1"))
671            .and(body_string_contains("install_id=install-1"))
672            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
673                "access_token": "access-2",
674                "token_type": "Bearer",
675                "expires_in": 900
676            })))
677            .mount(&server)
678            .await;
679
680        let request = RefreshRequest {
681            token_endpoint: format!("{}/token", server.uri()),
682            refresh_token: "refresh-1".into(),
683            client_id: "client-1".to_string(),
684            client_secret: None,
685            install_id: "install-1".to_string(),
686        };
687        let token = OAuthClient::default().refresh(&request).await.unwrap();
688
689        assert_eq!("access-2", token.access_token.expose());
690        assert_eq!(None, token.refresh_token);
691        assert!(token.expires_at.is_some());
692    }
693
694    #[cfg(feature = "reqwest")]
695    #[tokio::test]
696    async fn refresh_reports_an_unparsable_error_body() {
697        let server = MockServer::start().await;
698        Mock::given(method("POST"))
699            .respond_with(ResponseTemplate::new(503).set_body_string("upstream is down"))
700            .mount(&server)
701            .await;
702
703        let request = RefreshRequest {
704            token_endpoint: format!("{}/token", server.uri()),
705            refresh_token: "refresh-1".into(),
706            client_id: "client-1".to_string(),
707            client_secret: None,
708            install_id: "install-1".to_string(),
709        };
710        let error = OAuthClient::default().refresh(&request).await.unwrap_err();
711
712        assert_eq!(
713            "token request failed with status 503 Service Unavailable",
714            error.message()
715        );
716        assert_eq!(Some("upstream is down"), error.hint());
717    }
718
719    #[cfg(feature = "reqwest")]
720    #[tokio::test]
721    async fn a_plain_http_token_endpoint_is_refused() {
722        let request = RefreshRequest {
723            token_endpoint: "http://auth.example.com/token".to_string(),
724            refresh_token: "refresh-1".into(),
725            client_id: "client-1".to_string(),
726            client_secret: None,
727            install_id: "install-1".to_string(),
728        };
729        let error = OAuthClient::default().refresh(&request).await.unwrap_err();
730
731        assert_eq!(crate::error::ErrorCode::Usage, error.code());
732    }
733
734    fn metadata(issuer: &str) -> ServerMetadata {
735        ServerMetadata {
736            issuer: issuer.to_string(),
737            authorization_endpoint: format!("{issuer}/authorize"),
738            token_endpoint: format!("{issuer}/token"),
739            registration_endpoint: None,
740            scopes_supported: None,
741        }
742    }
743}