1pub mod flow;
2pub mod profile;
3pub mod providers;
4
5use crate::error::{Error, Result};
6use serde::Serialize;
7use std::path::PathBuf;
8use std::time::Duration;
9
10pub use profile::{ProfileConfig, Profiles, Tokens};
11pub use providers::{Client, Provider, DEFAULT_CLIENT, GMAIL, MICROSOFT, PROVIDERS};
12
13const REFRESH_SKEW: Duration = Duration::from_secs(120);
18
19const REPORT_MARGIN: Duration = Duration::from_secs(300);
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum TokenState {
25 Valid { expires_in: i64 },
26 Refreshable,
27 Expired,
28 Unknown(String),
29}
30
31impl TokenState {
32 pub fn is_usable(&self) -> bool {
33 matches!(self, TokenState::Valid { .. } | TokenState::Refreshable)
34 }
35}
36
37pub fn authorize_hint(profile: &str) -> String {
41 format!(
42 "this account authenticates with OAuth; if the token has expired, \
43 run `ecr oauth authorize {profile}`"
44 )
45}
46
47pub fn token_state(profiles: &Profiles, name: &str) -> TokenState {
50 let tokens = match profiles.load_tokens(name) {
51 Ok(tokens) => tokens,
52 Err(err) => return TokenState::Unknown(err.to_string()),
53 };
54
55 let expires_in = tokens.expires_in();
56 if expires_in > REPORT_MARGIN.as_secs() as i64 {
57 TokenState::Valid { expires_in }
58 } else if tokens.refresh_token.is_some() {
59 TokenState::Refreshable
60 } else {
61 TokenState::Expired
62 }
63}
64
65pub async fn access_token(profiles: &Profiles, name: &str) -> Result<String> {
67 Ok(ensure(profiles, name).await?.1.access_token)
68}
69
70pub async fn xoauth2(profiles: &Profiles, name: &str) -> Result<String> {
72 let (config, tokens) = ensure(profiles, name).await?;
73 Ok(flow::xoauth2(&config.email, &tokens.access_token))
74}
75
76async fn ensure(profiles: &Profiles, name: &str) -> Result<(ProfileConfig, Tokens)> {
77 let config = profiles.load_config(name)?;
78 let tokens = profiles.load_tokens(name)?;
79
80 if tokens.expires_in() > REFRESH_SKEW.as_secs() as i64 {
81 return Ok((config, tokens));
82 }
83
84 let refreshed = flow::refresh(&config, &tokens).await?;
85 profiles.save_tokens(name, &refreshed)?;
86 Ok((config, refreshed))
87}
88
89#[derive(Debug, Clone, Serialize)]
92pub struct Status {
93 pub profile: String,
94 pub provider: String,
95 pub email: String,
96 pub client_preset: Option<String>,
97 pub client_source: Option<String>,
98 pub expires_at: i64,
99 pub expires_in: i64,
100 pub has_refresh_token: bool,
101 pub scopes: Vec<String>,
102}
103
104pub fn status(profiles: &Profiles, name: &str) -> Result<Status> {
105 let config = profiles.load_config(name)?;
106 let tokens = profiles.load_tokens(name)?;
107 Ok(Status {
108 profile: config.profile,
109 provider: config.provider,
110 email: config.email,
111 client_preset: config.client_preset,
112 client_source: config.client_source,
113 expires_at: tokens.expires_at,
114 expires_in: tokens.expires_in(),
115 has_refresh_token: tokens.refresh_token.is_some(),
116 scopes: config.scopes,
117 })
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum Flow {
122 Auto,
126 AuthCode,
127 Device,
128}
129
130impl Flow {
131 fn resolve(self, config: &ProfileConfig) -> Flow {
132 match self {
133 Flow::Auto if config.device_authorize_url.is_some() => Flow::Device,
134 Flow::Auto => Flow::AuthCode,
135 other => other,
136 }
137 }
138}
139
140#[derive(Debug, Clone)]
141pub struct InitOptions {
142 pub profile: String,
143 pub provider: String,
144 pub email: String,
145 pub client_preset: Option<String>,
146 pub client_id: Option<String>,
147 pub client_secret: Option<String>,
148 pub tenant: Option<String>,
149 pub scopes: Vec<String>,
150 pub redirect_port: Option<u16>,
151 pub force: bool,
152}
153
154pub async fn init(profiles: &Profiles, options: InitOptions) -> Result<PathBuf> {
160 let path = profiles.config_path(&options.profile);
161 if path.exists() && !options.force {
162 return Err(Error::Oauth(format!(
163 "profile {:?} already exists at {}; pass --force to replace it",
164 options.profile,
165 path.display()
166 )));
167 }
168
169 let provider = providers::provider(&options.provider, options.tenant.as_deref())?;
170 let preset = match (&options.client_preset, &options.client_id) {
171 (Some(name), _) => Some(providers::client(&options.provider, Some(name))?),
172 (None, None) => Some(providers::client(&options.provider, None)?),
173 (None, Some(_)) => None,
174 };
175
176 let client_id = options
177 .client_id
178 .or_else(|| preset.as_ref().map(|c| c.client_id.clone()))
179 .ok_or_else(|| {
180 Error::Oauth("missing client id; pass --client-id or --client thunderbird".to_string())
181 })?;
182
183 let redirect_uri = match preset.as_ref().and_then(|c| c.redirect_uri.clone()) {
184 Some(uri) => uri,
185 None => {
186 let port = match options.redirect_port {
187 Some(port) => port,
188 None => flow::free_port().await?,
189 };
190 format!("http://127.0.0.1:{port}/callback")
191 }
192 };
193
194 let scopes = if options.scopes.is_empty() {
195 provider.scopes
196 } else {
197 options.scopes
198 };
199
200 let config = ProfileConfig {
201 profile: options.profile.clone(),
202 provider: options.provider,
203 email: options.email,
204 client_id,
205 client_secret: options
206 .client_secret
207 .or_else(|| preset.as_ref().and_then(|c| c.client_secret.clone())),
208 client_preset: preset.as_ref().map(|_| {
209 options
210 .client_preset
211 .clone()
212 .unwrap_or_else(|| DEFAULT_CLIENT.to_string())
213 }),
214 client_source: preset.as_ref().map(|c| c.source.clone()),
215 tenant: Some(provider.tenant),
216 authorize_url: provider.authorize_url,
217 token_url: provider.token_url,
218 device_authorize_url: provider.device_authorize_url,
219 scopes,
220 redirect_uri,
221 };
222
223 let written = profiles.save_config(&config)?;
224 if options.force {
227 profiles.forget_tokens(&options.profile);
228 }
229 Ok(written)
230}
231
232pub enum Prompt {
235 Browser { url: String, opened: bool },
237 Device {
239 user_code: String,
240 verification_uri: String,
241 message: Option<String>,
242 },
243}
244
245pub async fn authorize(
248 profiles: &Profiles,
249 name: &str,
250 requested: Flow,
251 timeout: Duration,
252 open: bool,
253 announce: impl FnOnce(Prompt),
254) -> Result<PathBuf> {
255 let config = profiles.load_config(name)?;
256
257 let tokens = match requested.resolve(&config) {
258 Flow::Device => {
259 let device = flow::begin_device(&config).await?;
260 announce(Prompt::Device {
261 user_code: device.user_code.clone(),
262 verification_uri: device.verification_uri.clone(),
263 message: device.message.clone(),
264 });
265 device.poll(&config, timeout).await?
266 }
267 _ => {
268 let authorization = flow::begin(&config).await?;
269 let opened = open && flow::open_browser(&authorization.url);
270 announce(Prompt::Browser {
271 url: authorization.url.clone(),
272 opened,
273 });
274 authorization.finish(&config, timeout).await?
275 }
276 };
277
278 profiles.save_tokens(name, &tokens)
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284 use profile::now;
285
286 fn store() -> (tempfile::TempDir, Profiles) {
287 let home = tempfile::tempdir().unwrap();
288 let profiles = Profiles::rooted_at(home.path());
289 (home, profiles)
290 }
291
292 async fn gmail(profiles: &Profiles, name: &str) {
293 init(
294 profiles,
295 InitOptions {
296 profile: name.to_string(),
297 provider: GMAIL.to_string(),
298 email: "alice@example.com".to_string(),
299 client_preset: None,
300 client_id: None,
301 client_secret: None,
302 tenant: None,
303 scopes: Vec::new(),
304 redirect_port: Some(49500),
305 force: false,
306 },
307 )
308 .await
309 .unwrap();
310 }
311
312 fn write_tokens(profiles: &Profiles, name: &str, expires_in: i64, refresh: Option<&str>) {
313 profiles
314 .save_tokens(
315 name,
316 &Tokens {
317 access_token: "at".into(),
318 refresh_token: refresh.map(str::to_string),
319 expires_at: now() + expires_in,
320 token_type: "Bearer".into(),
321 scope: None,
322 obtained_at: now(),
323 },
324 )
325 .unwrap();
326 }
327
328 #[tokio::test]
329 async fn init_takes_the_thunderbird_preset_by_default() {
330 let (_home, profiles) = store();
331 gmail(&profiles, "main").await;
332
333 let config = profiles.load_config("main").unwrap();
334 assert_eq!(config.client_preset.as_deref(), Some("thunderbird"));
335 assert!(config.client_secret.is_some());
336 assert_eq!(config.redirect_uri, "http://127.0.0.1:49500/callback");
337 assert_eq!(config.scopes, ["https://mail.google.com/"]);
338 }
339
340 #[tokio::test]
343 async fn an_explicit_client_id_takes_no_preset() {
344 let (_home, profiles) = store();
345 init(
346 &profiles,
347 InitOptions {
348 profile: "mine".into(),
349 provider: GMAIL.into(),
350 email: "alice@example.com".into(),
351 client_preset: None,
352 client_id: Some("my-own-id".into()),
353 client_secret: None,
354 tenant: None,
355 scopes: Vec::new(),
356 redirect_port: Some(49501),
357 force: false,
358 },
359 )
360 .await
361 .unwrap();
362
363 let config = profiles.load_config("mine").unwrap();
364 assert_eq!(config.client_id, "my-own-id");
365 assert_eq!(config.client_preset, None);
366 assert_eq!(config.client_secret, None);
367 assert_eq!(config.client_source, None);
368 }
369
370 #[tokio::test]
371 async fn microsoft_gets_the_device_endpoint_and_a_localhost_redirect() {
372 let (_home, profiles) = store();
373 init(
374 &profiles,
375 InitOptions {
376 profile: "work".into(),
377 provider: MICROSOFT.into(),
378 email: "bob@example.com".into(),
379 client_preset: None,
380 client_id: None,
381 client_secret: None,
382 tenant: None,
383 scopes: Vec::new(),
384 redirect_port: None,
385 force: false,
386 },
387 )
388 .await
389 .unwrap();
390
391 let config = profiles.load_config("work").unwrap();
392 assert!(config.device_authorize_url.is_some());
393 assert_eq!(config.redirect_uri, "https://localhost");
394 assert_eq!(Flow::Auto.resolve(&config), Flow::Device);
395 }
396
397 #[tokio::test]
398 async fn gmail_resolves_auto_to_the_browser_flow() {
399 let (_home, profiles) = store();
400 gmail(&profiles, "main").await;
401 let config = profiles.load_config("main").unwrap();
402 assert_eq!(Flow::Auto.resolve(&config), Flow::AuthCode);
403 }
404
405 #[tokio::test]
406 async fn an_explicit_flow_is_never_overridden() {
407 let (_home, profiles) = store();
408 gmail(&profiles, "main").await;
409 let config = profiles.load_config("main").unwrap();
410 assert_eq!(Flow::Device.resolve(&config), Flow::Device);
411 }
412
413 #[tokio::test]
414 async fn init_refuses_to_clobber_a_profile_without_force() {
415 let (_home, profiles) = store();
416 gmail(&profiles, "main").await;
417
418 let err = init(
419 &profiles,
420 InitOptions {
421 profile: "main".into(),
422 provider: GMAIL.into(),
423 email: "other@example.com".into(),
424 client_preset: None,
425 client_id: None,
426 client_secret: None,
427 tenant: None,
428 scopes: Vec::new(),
429 redirect_port: Some(49502),
430 force: false,
431 },
432 )
433 .await
434 .unwrap_err()
435 .to_string();
436 assert!(err.contains("--force"), "{err}");
437 assert_eq!(
438 profiles.load_config("main").unwrap().email,
439 "alice@example.com"
440 );
441 }
442
443 #[tokio::test]
447 async fn forcing_a_profile_drops_the_tokens_it_had() {
448 let (_home, profiles) = store();
449 gmail(&profiles, "main").await;
450 write_tokens(&profiles, "main", 3600, Some("rt"));
451
452 init(
453 &profiles,
454 InitOptions {
455 profile: "main".into(),
456 provider: GMAIL.into(),
457 email: "alice@example.com".into(),
458 client_preset: None,
459 client_id: None,
460 client_secret: None,
461 tenant: None,
462 scopes: Vec::new(),
463 redirect_port: Some(49503),
464 force: true,
465 },
466 )
467 .await
468 .unwrap();
469
470 assert!(!profiles.token_path("main").exists());
471 }
472
473 #[tokio::test]
474 async fn a_live_token_is_valid_and_an_expiring_one_is_refreshable() {
475 let (_home, profiles) = store();
476 gmail(&profiles, "main").await;
477
478 write_tokens(&profiles, "main", 3600, Some("rt"));
479 assert!(matches!(
480 token_state(&profiles, "main"),
481 TokenState::Valid { .. }
482 ));
483
484 write_tokens(&profiles, "main", 30, Some("rt"));
485 assert_eq!(token_state(&profiles, "main"), TokenState::Refreshable);
486 }
487
488 #[tokio::test]
489 async fn an_expired_token_with_no_refresh_token_is_expired() {
490 let (_home, profiles) = store();
491 gmail(&profiles, "main").await;
492 write_tokens(&profiles, "main", 0, None);
493 assert_eq!(token_state(&profiles, "main"), TokenState::Expired);
494 assert!(!token_state(&profiles, "main").is_usable());
495 }
496
497 #[test]
498 fn a_missing_profile_is_unknown_rather_than_a_false_pass() {
499 let (_home, profiles) = store();
500 let state = token_state(&profiles, "definitely-not-a-profile-xyzzy");
501 assert!(matches!(state, TokenState::Unknown(_)));
502 assert!(!state.is_usable());
503 }
504
505 #[tokio::test]
508 async fn a_live_token_is_served_without_reaching_the_provider() {
509 let (_home, profiles) = store();
510 gmail(&profiles, "main").await;
511 write_tokens(&profiles, "main", 3600, Some("rt"));
512
513 assert_eq!(access_token(&profiles, "main").await.unwrap(), "at");
514 assert_eq!(
515 xoauth2(&profiles, "main").await.unwrap(),
516 flow::xoauth2("alice@example.com", "at")
517 );
518 }
519
520 #[tokio::test]
521 async fn status_reports_what_a_script_used_to_read_from_oauthman() {
522 let (_home, profiles) = store();
523 gmail(&profiles, "main").await;
524 write_tokens(&profiles, "main", 3600, Some("rt"));
525
526 let status = status(&profiles, "main").unwrap();
527 assert_eq!(status.provider, "gmail");
528 assert_eq!(status.email, "alice@example.com");
529 assert!(status.has_refresh_token);
530 assert!(status.expires_in > 3500);
531
532 let json = serde_json::to_value(&status).unwrap();
533 for key in [
534 "profile",
535 "provider",
536 "email",
537 "client_preset",
538 "client_source",
539 "expires_at",
540 "expires_in",
541 "has_refresh_token",
542 "scopes",
543 ] {
544 assert!(json.get(key).is_some(), "status lost {key}");
545 }
546 }
547
548 #[tokio::test]
549 async fn authorize_hint_names_ecr_and_stays_conditional() {
550 let hint = authorize_hint("main");
551 assert!(hint.contains("ecr oauth authorize main"), "{hint}");
552 assert!(
553 hint.contains("if"),
554 "must not assert the token expired: {hint}"
555 );
556 }
557}