mxr_provider_gmail/
auth.rs1use 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
23pub 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 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 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 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}