Skip to main content

mxr_provider_gmail/
auth.rs

1use thiserror::Error;
2use yup_oauth2::InstalledFlowAuthenticator;
3use yup_oauth2::InstalledFlowReturnMethod;
4
5#[derive(Debug, Error)]
6pub enum AuthError {
7    #[error("OAuth2 error: {0}")]
8    OAuth2(String),
9
10    #[error("Token expired or missing")]
11    TokenExpired,
12
13    #[error("IO error: {0}")]
14    Io(#[from] std::io::Error),
15}
16
17const GMAIL_SCOPES: &[&str] = &[
18    "https://www.googleapis.com/auth/gmail.readonly",
19    "https://www.googleapis.com/auth/gmail.modify",
20    "https://www.googleapis.com/auth/gmail.labels",
21];
22
23/// Bundled OAuth client credentials for mxr.
24/// Users can override these with their own in config.toml (BYOC).
25/// Set GMAIL_CLIENT_ID and GMAIL_CLIENT_SECRET env vars at build time,
26/// or users provide their own via config.
27pub const BUNDLED_CLIENT_ID: Option<&str> = option_env!("GMAIL_CLIENT_ID");
28pub const BUNDLED_CLIENT_SECRET: Option<&str> = option_env!("GMAIL_CLIENT_SECRET");
29
30impl GmailAuth {
31    /// Create with bundled credentials, falling back to error if not compiled in.
32    pub fn with_bundled(token_ref: String) -> Result<Self, AuthError> {
33        let client_id = BUNDLED_CLIENT_ID
34            .ok_or_else(|| AuthError::OAuth2("no bundled client_id — rebuild with GMAIL_CLIENT_ID env var, or provide credentials in config.toml".into()))?;
35        let client_secret = BUNDLED_CLIENT_SECRET
36            .ok_or_else(|| AuthError::OAuth2("no bundled client_secret — rebuild with GMAIL_CLIENT_SECRET env var, or provide credentials in config.toml".into()))?;
37        Ok(Self::new(
38            client_id.to_string(),
39            client_secret.to_string(),
40            token_ref,
41        ))
42    }
43}
44
45pub struct GmailAuth {
46    client_id: String,
47    client_secret: String,
48    token_ref: String,
49    /// Stores a boxed function that returns an access token.
50    /// We use a trait object to avoid spelling out yup-oauth2's internal Authenticator type.
51    token_fn: Option<Box<dyn Fn() -> TokenFuture + Send + Sync>>,
52}
53
54type TokenFuture =
55    std::pin::Pin<Box<dyn std::future::Future<Output = Result<String, AuthError>> + Send>>;
56
57#[derive(serde::Deserialize)]
58struct RefreshTokenResponse {
59    access_token: Option<String>,
60    error: Option<String>,
61    error_description: Option<String>,
62}
63
64impl GmailAuth {
65    pub fn new(client_id: String, client_secret: String, token_ref: String) -> Self {
66        Self {
67            client_id,
68            client_secret,
69            token_ref,
70            token_fn: None,
71        }
72    }
73
74    pub fn with_refresh_token(
75        client_id: String,
76        client_secret: String,
77        refresh_token: String,
78    ) -> Self {
79        let token_client_id = client_id.clone();
80        let token_client_secret = client_secret.clone();
81        let token_fn = Box::new(move || {
82            let client_id = token_client_id.clone();
83            let client_secret = token_client_secret.clone();
84            let refresh_token = refresh_token.clone();
85            Box::pin(async move {
86                let response = reqwest::Client::new()
87                    .post("https://oauth2.googleapis.com/token")
88                    .form(&[
89                        ("client_id", client_id.as_str()),
90                        ("client_secret", client_secret.as_str()),
91                        ("refresh_token", refresh_token.as_str()),
92                        ("grant_type", "refresh_token"),
93                    ])
94                    .send()
95                    .await
96                    .map_err(|e| AuthError::OAuth2(e.to_string()))?;
97                let status = response.status();
98                let body: RefreshTokenResponse = response
99                    .json()
100                    .await
101                    .map_err(|e| AuthError::OAuth2(e.to_string()))?;
102
103                if !status.is_success() {
104                    return Err(AuthError::OAuth2(
105                        body.error_description.or(body.error).unwrap_or_else(|| {
106                            format!("token refresh failed with status {status}")
107                        }),
108                    ));
109                }
110
111                body.access_token.ok_or(AuthError::TokenExpired)
112            }) as TokenFuture
113        });
114
115        Self {
116            client_id,
117            client_secret,
118            token_ref: "refresh-token".into(),
119            token_fn: Some(token_fn),
120        }
121    }
122
123    fn token_path(&self) -> std::path::PathBuf {
124        let data_dir = dirs::data_dir()
125            .unwrap_or_else(|| std::path::PathBuf::from("."))
126            .join("mxr")
127            .join("tokens");
128        data_dir.join(format!("{}.json", self.token_ref))
129    }
130
131    fn make_secret(&self) -> yup_oauth2::ApplicationSecret {
132        yup_oauth2::ApplicationSecret {
133            client_id: self.client_id.clone(),
134            client_secret: self.client_secret.clone(),
135            auth_uri: "https://accounts.google.com/o/oauth2/auth".to_string(),
136            token_uri: "https://oauth2.googleapis.com/token".to_string(),
137            redirect_uris: vec!["http://localhost".to_string()],
138            ..Default::default()
139        }
140    }
141
142    pub async fn interactive_auth(&mut self) -> Result<(), AuthError> {
143        let secret = self.make_secret();
144        let token_path = self.token_path();
145
146        if let Some(parent) = token_path.parent() {
147            tokio::fs::create_dir_all(parent).await?;
148        }
149
150        let auth =
151            InstalledFlowAuthenticator::builder(secret, InstalledFlowReturnMethod::HTTPRedirect)
152                .persist_tokens_to_disk(token_path)
153                .build()
154                .await
155                .map_err(|e| AuthError::OAuth2(e.to_string()))?;
156
157        // Force token fetch for the interactive flow
158        let _token = auth
159            .token(GMAIL_SCOPES)
160            .await
161            .map_err(|e| AuthError::OAuth2(e.to_string()))?;
162
163        let auth = std::sync::Arc::new(auth);
164        self.token_fn = Some(Box::new(move || {
165            let auth = auth.clone();
166            Box::pin(async move {
167                let tok = auth
168                    .token(GMAIL_SCOPES)
169                    .await
170                    .map_err(|e| AuthError::OAuth2(e.to_string()))?;
171                tok.token()
172                    .map(|t| t.to_string())
173                    .ok_or(AuthError::TokenExpired)
174            })
175        }));
176
177        Ok(())
178    }
179
180    pub async fn load_existing(&mut self) -> Result<(), AuthError> {
181        let token_path = self.token_path();
182        if !token_path.exists() {
183            return Err(AuthError::TokenExpired);
184        }
185
186        let secret = self.make_secret();
187        let auth =
188            InstalledFlowAuthenticator::builder(secret, InstalledFlowReturnMethod::HTTPRedirect)
189                .persist_tokens_to_disk(token_path)
190                .build()
191                .await
192                .map_err(|e| AuthError::OAuth2(e.to_string()))?;
193
194        let auth = std::sync::Arc::new(auth);
195        self.token_fn = Some(Box::new(move || {
196            let auth = auth.clone();
197            Box::pin(async move {
198                let tok = auth
199                    .token(GMAIL_SCOPES)
200                    .await
201                    .map_err(|e| AuthError::OAuth2(e.to_string()))?;
202                tok.token()
203                    .map(|t| t.to_string())
204                    .ok_or(AuthError::TokenExpired)
205            })
206        }));
207
208        Ok(())
209    }
210
211    pub async fn access_token(&self) -> Result<String, AuthError> {
212        let token_fn = self.token_fn.as_ref().ok_or(AuthError::TokenExpired)?;
213        (token_fn)().await
214    }
215}