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
13pub const CALLBACK_PORTS: std::ops::Range<u16> = 49152..49252;
16
17#[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
37fn 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
124pub 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, ¶ms).await?;
148 tokens_from(&response, Some(refresh_token))
149}
150
151pub 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
172pub 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
192pub 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 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 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 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
350pub 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, ¶ms).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 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
439pub 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 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 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 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 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 #[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 #[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 #[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}