Skip to main content

ecr_store/oauth/
mod.rs

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
13/// Refresh this far ahead of expiry.
14///
15/// mbsync asks for a token and then spends minutes syncing; handing it one that
16/// expires in ten seconds is handing it a failure halfway through.
17const REFRESH_SKEW: Duration = Duration::from_secs(120);
18
19/// Below this, doctor reports a token as expiring rather than valid. Wider than
20/// the refresh skew on purpose: doctor is reporting, not deciding.
21const 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
37/// Appended to a send failure when the account authenticates with OAuth: a
38/// token expiry is the most likely cause, so the fix is named rather than
39/// leaving the user to decode msmtp's stderr.
40pub 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
47/// What doctor reports. Reads the files rather than refreshing: doctor says what
48/// is, and a check that quietly fixed what it was checking could never fail.
49pub 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
65/// A valid access token, refreshed and written back if it was about to expire.
66pub async fn access_token(profiles: &Profiles, name: &str) -> Result<String> {
67    Ok(ensure(profiles, name).await?.1.access_token)
68}
69
70/// The base64 XOAUTH2 string, which is what mbsync and msmtp actually consume.
71pub 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/// The shape `ecr oauth status` prints. The field names are oauthman's, so a
90/// script that read its output keeps working.
91#[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    /// The device flow where the provider offers one, the browser flow
123    /// otherwise. Microsoft's built-in client cannot serve a loopback redirect,
124    /// so for it this is the only flow that works.
125    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
154/// Write a profile, without authorizing it.
155///
156/// A profile with an explicit `--client-id` gets no preset: the caller brought
157/// their own client, and recording Thunderbird's secret beside it would be a
158/// lie about where the credential came from.
159pub 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    // A replaced profile's tokens belong to the client that is gone, and a
225    // refresh against the new one would fail with nothing naming why.
226    if options.force {
227        profiles.forget_tokens(&options.profile);
228    }
229    Ok(written)
230}
231
232/// What `authorize` needs the caller to put in front of the user. The flows
233/// differ in what that is, and only the caller knows where it can be printed.
234pub enum Prompt {
235    /// Open this URL. `opened` is whether a browser already took it.
236    Browser { url: String, opened: bool },
237    /// Type this code at this address.
238    Device {
239        user_code: String,
240        verification_uri: String,
241        message: Option<String>,
242    },
243}
244
245/// Run a flow to completion, calling `announce` once the user has something to
246/// act on and before the wait begins.
247pub 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    /// A caller who brought their own client id did not bring Thunderbird's
341    /// secret, and recording one would misreport where the credential came from.
342    #[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    /// Tokens belong to the client that obtained them. Replacing the profile and
444    /// keeping them would leave a refresh failing against a client that never
445    /// issued them, with nothing naming why.
446    #[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    /// A token still comfortably alive must not cost a network round trip:
506    /// mbsync asks for one on every channel of every sync.
507    #[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}