Skip to main content

ecr_store/oauth/
flow.rs

1use crate::error::{Error, Result};
2use crate::oauth::profile::{now, ProfileConfig, Tokens};
3use base64::engine::general_purpose::{STANDARD_NO_PAD, URL_SAFE_NO_PAD};
4use base64::Engine as _;
5use rand::Rng;
6use reqwest::Url;
7use sha2::{Digest, Sha256};
8use std::collections::HashMap;
9use std::time::Duration;
10use tokio::io::{AsyncReadExt, AsyncWriteExt};
11use tokio::net::{TcpListener, TcpStream};
12
13/// The ports oauthman picked from, kept so a profile carried over from it keeps
14/// working against a redirect URI the provider has already seen.
15pub const CALLBACK_PORTS: std::ops::Range<u16> = 49152..49252;
16
17/// A token endpoint's own refusal, with the `error` code it named.
18///
19/// The device flow polls until the user finishes, and *every* poll before that
20/// is an HTTP 400 saying `authorization_pending`. Parsing the code out is what
21/// separates "keep waiting" from "this will never work" — matching on substrings
22/// of the raw body would treat a client-id typo as something to sit through.
23#[derive(Debug)]
24struct TokenError {
25    code: Option<String>,
26    body: String,
27}
28
29impl From<TokenError> for Error {
30    fn from(err: TokenError) -> Self {
31        Error::Oauth(err.body)
32    }
33}
34
35type TokenResult<T> = std::result::Result<T, TokenError>;
36
37/// `reqwest::Client::new()` *panics* when it cannot load the system trust
38/// store, so it is never used: a machine with no CA bundle is a bad
39/// configuration, not a reason for ecr to abort.
40fn https() -> TokenResult<reqwest::Client> {
41    reqwest::Client::builder()
42        .build()
43        .map_err(|err| TokenError {
44            code: None,
45            body: format!("could not start an HTTPS client: {err}"),
46        })
47}
48
49async fn post_form(url: &str, params: &HashMap<&str, String>) -> TokenResult<serde_json::Value> {
50    let response = https()?
51        .post(url)
52        .form(params)
53        .send()
54        .await
55        .map_err(|err| TokenError {
56            code: None,
57            body: format!("could not reach {url}: {err}"),
58        })?;
59
60    let status = response.status();
61    let body = response.text().await.unwrap_or_default();
62    let parsed: Option<serde_json::Value> = serde_json::from_str(&body).ok();
63
64    if status.is_success() {
65        return parsed.ok_or_else(|| TokenError {
66            code: None,
67            body: format!("{url} answered {status} with something that is not JSON: {body}"),
68        });
69    }
70
71    let code = parsed
72        .as_ref()
73        .and_then(|v| v.get("error"))
74        .and_then(|v| v.as_str())
75        .map(str::to_string);
76    let description = parsed
77        .as_ref()
78        .and_then(|v| v.get("error_description"))
79        .and_then(|v| v.as_str())
80        .map(str::to_string);
81
82    Err(TokenError {
83        body: match (&code, &description) {
84            (Some(code), Some(text)) => format!("{url} refused: {code}: {text}"),
85            (Some(code), None) => format!("{url} refused: {code}"),
86            _ => format!("{url} answered {status}: {body}"),
87        },
88        code,
89    })
90}
91
92fn tokens_from(response: &serde_json::Value, fallback_refresh: Option<&str>) -> Result<Tokens> {
93    let access_token = response
94        .get("access_token")
95        .and_then(|v| v.as_str())
96        .ok_or_else(|| {
97            Error::Oauth(format!(
98                "token response carried no access_token: {response}"
99            ))
100        })?
101        .to_string();
102
103    let string = |key: &str| {
104        response
105            .get(key)
106            .and_then(|v| v.as_str())
107            .map(str::to_string)
108    };
109
110    Ok(Tokens {
111        access_token,
112        refresh_token: string("refresh_token").or_else(|| fallback_refresh.map(str::to_string)),
113        expires_at: now()
114            + response
115                .get("expires_in")
116                .and_then(|v| v.as_i64())
117                .unwrap_or(3600),
118        token_type: string("token_type").unwrap_or_else(|| "Bearer".to_string()),
119        scope: string("scope"),
120        obtained_at: now(),
121    })
122}
123
124/// Trade the refresh token for a fresh access token.
125///
126/// The response need not carry a refresh token — Google only returns one on the
127/// first authorization — so the existing one is carried forward rather than
128/// dropped, which would turn every refresh into the last one.
129pub async fn refresh(config: &ProfileConfig, tokens: &Tokens) -> Result<Tokens> {
130    let refresh_token = tokens.refresh_token.as_deref().ok_or_else(|| {
131        Error::Oauth(format!(
132            "profile {:?} has no refresh token; run `ecr oauth authorize {}`",
133            config.profile, config.profile
134        ))
135    })?;
136
137    let mut params = HashMap::from([
138        ("grant_type", "refresh_token".to_string()),
139        ("client_id", config.client_id.clone()),
140        ("refresh_token", refresh_token.to_string()),
141        ("scope", config.scopes.join(" ")),
142    ]);
143    if let Some(secret) = &config.client_secret {
144        params.insert("client_secret", secret.clone());
145    }
146
147    let response = post_form(&config.token_url, &params).await?;
148    tokens_from(&response, Some(refresh_token))
149}
150
151/// The base64 XOAUTH2 string IMAP and SMTP want.
152pub fn xoauth2(email: &str, access_token: &str) -> String {
153    STANDARD_NO_PAD.encode(format!(
154        "user={email}\x01auth=Bearer {access_token}\x01\x01"
155    ))
156}
157
158fn pkce_pair() -> (String, String) {
159    let mut bytes = [0u8; 64];
160    rand::rng().fill_bytes(&mut bytes);
161    let verifier = URL_SAFE_NO_PAD.encode(bytes);
162    let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
163    (verifier, challenge)
164}
165
166fn random_state() -> String {
167    let mut bytes = [0u8; 24];
168    rand::rng().fill_bytes(&mut bytes);
169    URL_SAFE_NO_PAD.encode(bytes)
170}
171
172/// A free loopback port from the range oauthman used.
173pub async fn free_port() -> Result<u16> {
174    for port in CALLBACK_PORTS {
175        if TcpListener::bind(("127.0.0.1", port)).await.is_ok() {
176            return Ok(port);
177        }
178    }
179    Err(Error::Oauth(
180        "no free loopback port for the OAuth callback".to_string(),
181    ))
182}
183
184pub struct Authorization {
185    pub url: String,
186    listener: TcpListener,
187    verifier: String,
188    state: String,
189    redirect_uri: String,
190}
191
192/// Build the authorization URL and start listening for the callback *before*
193/// the browser opens.
194///
195/// Binding first is what makes the race impossible: a provider that answers
196/// instantly would otherwise redirect to a port nothing is listening on yet,
197/// and the user would see a connection refused page with the code already spent.
198pub async fn begin(config: &ProfileConfig) -> Result<Authorization> {
199    let redirect = Url::parse(&config.redirect_uri)
200        .map_err(|err| Error::Oauth(format!("profile has an unreadable redirect_uri: {err}")))?;
201
202    let port = match (redirect.scheme(), redirect.port()) {
203        ("http", Some(port)) => port,
204        _ => {
205            return Err(Error::Oauth(format!(
206                "redirect_uri {} is not a loopback address this can serve, so the browser \
207                 flow cannot complete; authorize this profile with `--flow device` instead",
208                config.redirect_uri
209            )))
210        }
211    };
212
213    let listener = TcpListener::bind(("127.0.0.1", port))
214        .await
215        .map_err(|err| {
216            Error::Oauth(format!(
217                "could not listen on 127.0.0.1:{port} for the OAuth callback: {err}"
218            ))
219        })?;
220
221    let (verifier, challenge) = pkce_pair();
222    let state = random_state();
223
224    let mut url = Url::parse(&config.authorize_url)
225        .map_err(|err| Error::Oauth(format!("profile has an unreadable authorize_url: {err}")))?;
226    url.query_pairs_mut()
227        .append_pair("client_id", &config.client_id)
228        .append_pair("response_type", "code")
229        .append_pair("redirect_uri", &config.redirect_uri)
230        .append_pair("response_mode", "query")
231        .append_pair("scope", &config.scopes.join(" "))
232        .append_pair("state", &state)
233        .append_pair("code_challenge", &challenge)
234        .append_pair("code_challenge_method", "S256")
235        .append_pair("login_hint", &config.email);
236
237    Ok(Authorization {
238        url: url.to_string(),
239        listener,
240        verifier,
241        state,
242        redirect_uri: config.redirect_uri.clone(),
243    })
244}
245
246impl Authorization {
247    /// Wait for the browser to come back, then trade the code for tokens.
248    pub async fn finish(self, config: &ProfileConfig, timeout: Duration) -> Result<Tokens> {
249        let params = tokio::time::timeout(timeout, self.await_callback())
250            .await
251            .map_err(|_| {
252                Error::Oauth(format!(
253                    "timed out after {}s waiting for the OAuth callback",
254                    timeout.as_secs()
255                ))
256            })??;
257
258        if params.get("state").map(String::as_str) != Some(self.state.as_str()) {
259            return Err(Error::Oauth(
260                "OAuth state mismatch: the reply did not come from the request that was sent"
261                    .to_string(),
262            ));
263        }
264        if let Some(error) = params.get("error") {
265            let detail = params.get("error_description").unwrap_or(error);
266            return Err(Error::Oauth(format!("authorization was refused: {detail}")));
267        }
268        let code = params.get("code").ok_or_else(|| {
269            Error::Oauth("the callback carried no authorization code".to_string())
270        })?;
271
272        let mut form = HashMap::from([
273            ("grant_type", "authorization_code".to_string()),
274            ("client_id", config.client_id.clone()),
275            ("code", code.clone()),
276            ("redirect_uri", self.redirect_uri.clone()),
277            ("code_verifier", self.verifier.clone()),
278        ]);
279        if let Some(secret) = &config.client_secret {
280            form.insert("client_secret", secret.clone());
281        }
282
283        let response = post_form(&config.token_url, &form).await?;
284        tokens_from(&response, None)
285    }
286
287    /// Accept connections until one of them is the callback.
288    ///
289    /// Anything else — a favicon request, a stray probe — is answered and
290    /// ignored rather than mistaken for the reply, which would abandon the flow
291    /// with no code and no way to tell why.
292    async fn await_callback(&self) -> Result<HashMap<String, String>> {
293        loop {
294            let Ok((stream, _)) = self.listener.accept().await else {
295                continue;
296            };
297            if let Some(params) = handle(stream).await {
298                return Ok(params);
299            }
300        }
301    }
302}
303
304async fn handle(mut stream: TcpStream) -> Option<HashMap<String, String>> {
305    let mut buffer = [0u8; 8192];
306    let read = stream.read(&mut buffer).await.ok()?;
307    let request = String::from_utf8_lossy(&buffer[..read]);
308    let target = request.split_whitespace().nth(1)?;
309
310    // Only the path and query matter, and only a real URL parser gets the
311    // percent-decoding right; the base is thrown away.
312    let url = Url::parse("http://127.0.0.1").ok()?.join(target).ok()?;
313    if url.path() != "/callback" {
314        respond(&mut stream, "404 Not Found", "not the OAuth callback\n").await;
315        return None;
316    }
317
318    let params: HashMap<String, String> = url
319        .query_pairs()
320        .map(|(k, v)| (k.into_owned(), v.into_owned()))
321        .collect();
322
323    let body = if params.contains_key("error") {
324        "Authorization failed. Check the terminal.\n"
325    } else {
326        "Authorized. You can close this window.\n"
327    };
328    respond(&mut stream, "200 OK", body).await;
329    Some(params)
330}
331
332async fn respond(stream: &mut TcpStream, status: &str, body: &str) {
333    let response = format!(
334        "HTTP/1.1 {status}\r\nContent-Type: text/plain; charset=utf-8\r\n\
335         Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
336        body.len()
337    );
338    let _ = stream.write_all(response.as_bytes()).await;
339    let _ = stream.flush().await;
340}
341
342pub struct DeviceCode {
343    pub user_code: String,
344    pub verification_uri: String,
345    pub message: Option<String>,
346    device_code: String,
347    interval: u64,
348}
349
350/// Ask for a device code, which the user types into a browser anywhere.
351///
352/// This is the flow Microsoft gets: Thunderbird's Microsoft client registers
353/// `https://localhost` as its redirect, which no local HTTP listener can serve.
354pub async fn begin_device(config: &ProfileConfig) -> Result<DeviceCode> {
355    let url = config.device_authorize_url.as_deref().ok_or_else(|| {
356        Error::Oauth(format!(
357            "provider {} does not offer the device flow",
358            config.provider
359        ))
360    })?;
361
362    let params = HashMap::from([
363        ("client_id", config.client_id.clone()),
364        ("scope", config.scopes.join(" ")),
365    ]);
366    let response = post_form(url, &params).await?;
367
368    let string = |key: &str| {
369        response
370            .get(key)
371            .and_then(|v| v.as_str())
372            .map(str::to_string)
373    };
374
375    Ok(DeviceCode {
376        user_code: string("user_code")
377            .ok_or_else(|| Error::Oauth("no user_code in the device response".to_string()))?,
378        verification_uri: string("verification_uri")
379            .or_else(|| string("verification_url"))
380            .ok_or_else(|| {
381                Error::Oauth("no verification_uri in the device response".to_string())
382            })?,
383        message: string("message"),
384        device_code: string("device_code")
385            .ok_or_else(|| Error::Oauth("no device_code in the device response".to_string()))?,
386        interval: response
387            .get("interval")
388            .and_then(|v| v.as_u64())
389            .unwrap_or(5),
390    })
391}
392
393impl DeviceCode {
394    /// Poll until the user finishes in the browser.
395    ///
396    /// `authorization_pending` is the ordinary answer and `slow_down` asks for a
397    /// longer gap; every other code ends the wait, because nothing the user does
398    /// in the browser will fix a bad client id.
399    pub async fn poll(self, config: &ProfileConfig, timeout: Duration) -> Result<Tokens> {
400        let deadline = tokio::time::Instant::now() + timeout;
401        let mut interval = self.interval;
402
403        let mut form = HashMap::from([
404            (
405                "grant_type",
406                "urn:ietf:params:oauth:grant-type:device_code".to_string(),
407            ),
408            ("client_id", config.client_id.clone()),
409            ("device_code", self.device_code.clone()),
410        ]);
411        if let Some(secret) = &config.client_secret {
412            form.insert("client_secret", secret.clone());
413        }
414
415        while tokio::time::Instant::now() < deadline {
416            tokio::time::sleep(Duration::from_secs(interval)).await;
417            match post_form(&config.token_url, &form).await {
418                Ok(response) => return tokens_from(&response, None),
419                Err(err) => match err.code.as_deref() {
420                    Some("authorization_pending") => continue,
421                    Some("slow_down") => interval += 5,
422                    Some("expired_token") => {
423                        return Err(Error::Oauth(
424                            "the device code expired before it was entered".to_string(),
425                        ))
426                    }
427                    _ => return Err(err.into()),
428                },
429            }
430        }
431
432        Err(Error::Oauth(format!(
433            "timed out after {}s waiting for the device code to be entered",
434            timeout.as_secs()
435        )))
436    }
437}
438
439/// Hand a URL to the desktop, reporting whether anything took it.
440///
441/// A headless session has no browser, and pretending otherwise would leave the
442/// user watching a flow that cannot start; the caller prints the URL instead.
443pub fn open_browser(url: &str) -> bool {
444    let opener = if cfg!(target_os = "macos") {
445        "open"
446    } else {
447        "xdg-open"
448    };
449    std::process::Command::new(opener)
450        .arg(url)
451        .stdout(std::process::Stdio::null())
452        .stderr(std::process::Stdio::null())
453        .status()
454        .map(|status| status.success())
455        .unwrap_or(false)
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461
462    #[test]
463    fn the_xoauth2_string_is_what_imap_expects() {
464        let encoded = xoauth2("alice@example.com", "tok");
465        let decoded = String::from_utf8(STANDARD_NO_PAD.decode(&encoded).unwrap()).unwrap();
466        assert_eq!(decoded, "user=alice@example.com\x01auth=Bearer tok\x01\x01");
467    }
468
469    #[test]
470    fn the_pkce_challenge_is_the_sha256_of_the_verifier() {
471        let (verifier, challenge) = pkce_pair();
472        assert_eq!(
473            challenge,
474            URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()))
475        );
476        // Both must survive a URL query untouched.
477        assert!(!verifier.contains(['+', '/', '=']), "{verifier}");
478        assert!(!challenge.contains(['+', '/', '=']), "{challenge}");
479    }
480
481    #[test]
482    fn two_flows_never_share_a_verifier_or_a_state() {
483        assert_ne!(pkce_pair().0, pkce_pair().0);
484        assert_ne!(random_state(), random_state());
485    }
486
487    fn gmail_profile(redirect: &str) -> ProfileConfig {
488        ProfileConfig {
489            profile: "main".into(),
490            provider: "gmail".into(),
491            email: "alice@example.com".into(),
492            client_id: "cid".into(),
493            client_secret: None,
494            client_preset: None,
495            client_source: None,
496            tenant: None,
497            authorize_url: "https://accounts.google.com/o/oauth2/v2/auth".into(),
498            token_url: "https://oauth2.googleapis.com/token".into(),
499            device_authorize_url: None,
500            scopes: vec!["https://mail.google.com/".into()],
501            redirect_uri: redirect.to_string(),
502        }
503    }
504
505    #[tokio::test]
506    async fn the_authorize_url_carries_pkce_and_the_login_hint() {
507        let (_config, auth, _port) = begin_bound().await;
508
509        let url = Url::parse(&auth.url).unwrap();
510        let params: HashMap<_, _> = url.query_pairs().collect();
511        assert_eq!(params["code_challenge_method"], "S256");
512        assert_eq!(params["login_hint"], "alice@example.com");
513        assert_eq!(params["client_id"], "cid");
514        assert_eq!(params["response_type"], "code");
515        assert_eq!(params["scope"], "https://mail.google.com/");
516        assert!(!params["code_challenge"].is_empty());
517    }
518
519    /// A port the kernel has just handed out.
520    ///
521    /// Not `free_port`: that scans the range from the bottom and drops its probe
522    /// immediately, so every test running in parallel is handed the same number
523    /// and all but one fail to bind. The kernel's ephemeral ports are dealt out
524    /// round-robin, which is the property the tests actually need.
525    async fn ephemeral_port() -> u16 {
526        let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
527        let port = listener.local_addr().unwrap().port();
528        drop(listener);
529        port
530    }
531
532    /// A started authorization, on whatever port could actually be bound.
533    ///
534    /// Any "find a free port, then bind it" pair has a gap, and with the whole
535    /// suite running in parallel something else claims the port inside that gap
536    /// often enough to fail a run. Production is right to report that as an
537    /// error — a real callback has to arrive on the port the provider was told
538    /// about — so the retry belongs here rather than in `begin`.
539    async fn begin_bound() -> (ProfileConfig, Authorization, u16) {
540        for _ in 0..64 {
541            let port = ephemeral_port().await;
542            let config = gmail_profile(&format!("http://127.0.0.1:{port}/callback"));
543            if let Ok(auth) = begin(&config).await {
544                return (config, auth, port);
545            }
546        }
547        panic!("no loopback port stayed free long enough to bind");
548    }
549
550    /// `begin` and `begin_device` hold the PKCE verifier and the device code,
551    /// so they deliberately do not derive Debug and `unwrap_err` is unavailable.
552    fn message<T>(result: Result<T>) -> String {
553        match result {
554            Ok(_) => panic!("expected this to be refused"),
555            Err(err) => err.to_string(),
556        }
557    }
558
559    /// Thunderbird's Microsoft client redirects to `https://localhost`, which no
560    /// local listener can answer. Refusing here is what turns a flow that hangs
561    /// until it times out into one sentence naming the flag that works.
562    #[tokio::test]
563    async fn a_redirect_that_cannot_be_served_names_the_device_flow() {
564        let config = gmail_profile("https://localhost");
565        let err = message(begin(&config).await);
566        assert!(err.contains("--flow device"), "{err}");
567    }
568
569    /// Binding before the browser opens is the whole reason `begin` is separate
570    /// from `finish`; a port that is still free afterwards means it is not.
571    #[tokio::test]
572    async fn the_callback_port_is_held_before_the_browser_opens() {
573        let (_config, _auth, port) = begin_bound().await;
574        assert!(TcpListener::bind(("127.0.0.1", port)).await.is_err());
575    }
576
577    #[tokio::test]
578    async fn the_callback_is_read_off_a_real_request() {
579        let (_config, auth, port) = begin_bound().await;
580        let state = auth.state.clone();
581        let sent = state.clone();
582
583        tokio::spawn(async move {
584            let mut stream = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
585            let request = format!(
586                "GET /callback?code=the-code&state={sent} HTTP/1.1\r\nHost: localhost\r\n\r\n"
587            );
588            stream.write_all(request.as_bytes()).await.unwrap();
589            let mut reply = String::new();
590            stream.read_to_string(&mut reply).await.unwrap();
591            assert!(reply.contains("Authorized"), "{reply}");
592        });
593
594        let params = tokio::time::timeout(Duration::from_secs(5), auth.await_callback())
595            .await
596            .expect("callback never arrived")
597            .unwrap();
598        assert_eq!(params["code"], "the-code");
599        assert_eq!(params["state"], state);
600    }
601
602    /// A browser asking for /favicon.ico must not be mistaken for the reply.
603    #[tokio::test]
604    async fn a_request_that_is_not_the_callback_is_ignored() {
605        let (_config, auth, port) = begin_bound().await;
606        let state = auth.state.clone();
607
608        tokio::spawn(async move {
609            for target in ["/favicon.ico", "/callback?code=real&state=STATE"] {
610                let target = target.replace("STATE", &state);
611                let mut stream = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
612                let request = format!("GET {target} HTTP/1.1\r\nHost: localhost\r\n\r\n");
613                stream.write_all(request.as_bytes()).await.unwrap();
614                let mut reply = String::new();
615                let _ = stream.read_to_string(&mut reply).await;
616            }
617        });
618
619        let params = tokio::time::timeout(Duration::from_secs(5), auth.await_callback())
620            .await
621            .expect("callback never arrived")
622            .unwrap();
623        assert_eq!(params["code"], "real");
624    }
625
626    #[tokio::test]
627    async fn a_forged_callback_is_refused() {
628        let (config, auth, port) = begin_bound().await;
629
630        tokio::spawn(async move {
631            let mut stream = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
632            let request =
633                "GET /callback?code=stolen&state=not-the-state HTTP/1.1\r\nHost: x\r\n\r\n";
634            stream.write_all(request.as_bytes()).await.unwrap();
635            let mut reply = String::new();
636            let _ = stream.read_to_string(&mut reply).await;
637        });
638
639        let err = auth
640            .finish(&config, Duration::from_secs(5))
641            .await
642            .unwrap_err()
643            .to_string();
644        assert!(err.contains("state mismatch"), "{err}");
645    }
646
647    #[tokio::test]
648    async fn a_provider_that_refuses_says_so_rather_than_timing_out() {
649        let (config, auth, port) = begin_bound().await;
650        let state = auth.state.clone();
651
652        tokio::spawn(async move {
653            let mut stream = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
654            let request = format!(
655                "GET /callback?error=access_denied&error_description=user+said+no&state={state} \
656                 HTTP/1.1\r\nHost: x\r\n\r\n"
657            );
658            stream.write_all(request.as_bytes()).await.unwrap();
659            let mut reply = String::new();
660            let _ = stream.read_to_string(&mut reply).await;
661        });
662
663        let err = auth
664            .finish(&config, Duration::from_secs(5))
665            .await
666            .unwrap_err()
667            .to_string();
668        assert!(err.contains("user said no"), "{err}");
669    }
670
671    #[tokio::test]
672    async fn a_browser_that_never_comes_back_times_out() {
673        let (config, auth, _port) = begin_bound().await;
674
675        let err = auth
676            .finish(&config, Duration::from_millis(50))
677            .await
678            .unwrap_err()
679            .to_string();
680        assert!(err.contains("timed out"), "{err}");
681    }
682
683    #[tokio::test]
684    async fn the_device_flow_is_refused_for_a_provider_without_one() {
685        let config = gmail_profile("http://127.0.0.1:49152/callback");
686        let err = message(begin_device(&config).await);
687        assert!(err.contains("does not offer the device flow"), "{err}");
688    }
689
690    #[test]
691    fn a_refresh_response_without_a_refresh_token_keeps_the_old_one() {
692        let response = serde_json::json!({"access_token": "new", "expires_in": 3599});
693        let tokens = tokens_from(&response, Some("keep-me")).unwrap();
694        assert_eq!(tokens.access_token, "new");
695        assert_eq!(tokens.refresh_token.as_deref(), Some("keep-me"));
696        assert!(tokens.expires_in() > 3500);
697    }
698
699    #[test]
700    fn a_response_with_no_access_token_is_an_error_not_an_empty_token() {
701        let response = serde_json::json!({"expires_in": 3599});
702        assert!(tokens_from(&response, None).is_err());
703    }
704
705    #[tokio::test]
706    async fn refreshing_without_a_refresh_token_names_the_fix() {
707        let config = gmail_profile("http://127.0.0.1:49152/callback");
708        let tokens = Tokens {
709            access_token: "at".into(),
710            refresh_token: None,
711            expires_at: 0,
712            token_type: "Bearer".into(),
713            scope: None,
714            obtained_at: 0,
715        };
716        let err = refresh(&config, &tokens).await.unwrap_err().to_string();
717        assert!(err.contains("ecr oauth authorize main"), "{err}");
718    }
719}