Prepare official Google OAuth setup
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
18935adf25ac285d7472deab91667e72536cec81- Parents
-
97c7de7 - Tree
7aa9fee
18935ad
18935adf25ac285d7472deab91667e72536cec8197c7de7
7aa9fee| Status | File | + | - |
|---|---|---|---|
| M |
README.md
|
19 | 9 |
| M |
src/cli.rs
|
52 | 6 |
| M |
src/config.rs
|
21 | 13 |
| M |
src/providers.rs
|
26 | 1 |
README.mdmodified@@ -34,7 +34,7 @@ rcal providers google auth login --account ID | ||
| 34 | 34 | rcal providers google auth logout --account ID |
| 35 | 35 | rcal providers google auth inspect --account ID |
| 36 | 36 | rcal providers google calendars list --account ID |
| 37 | -rcal providers google setup --account ID --client-id ID [--client-secret SECRET] [--calendar ID] | |
| 37 | +rcal providers google setup --account ID [--client-id ID] [--client-secret SECRET] [--calendar ID] | |
| 38 | 38 | rcal providers google sync [--account ID] |
| 39 | 39 | rcal providers google status |
| 40 | 40 | rcal reminders run [--events-file PATH] [--state-file PATH] [--once] |
@@ -204,13 +204,12 @@ daemon does not sync remote calendars itself. | ||
| 204 | 204 | Google Calendar support is also cache-first. You refresh remote data explicitly: |
| 205 | 205 | |
| 206 | 206 | ```sh |
| 207 | -rcal providers google setup --account personal --client-id GOOGLE_CLIENT_ID | |
| 207 | +rcal providers google setup --account personal | |
| 208 | 208 | rcal |
| 209 | 209 | ``` |
| 210 | 210 | |
| 211 | -Google setup currently needs your own Google OAuth Desktop client ID. If Google | |
| 212 | -also gives you a client secret, pass `--client-secret GOOGLE_CLIENT_SECRET`. | |
| 213 | -The setup flow opens browser auth, selects your default editable calendar, | |
| 211 | +The setup flow uses rcal's official Google OAuth Desktop client when the build | |
| 212 | +includes one. It opens browser auth, selects your default editable calendar, | |
| 214 | 213 | writes `~/.config/rcal/config.toml`, performs the first sync, and sets new |
| 215 | 214 | event creation to Google by default. User tokens are stored in the OS keychain. |
| 216 | 215 | |
@@ -242,12 +241,22 @@ sync_future_days = 365 | ||
| 242 | 241 | |
| 243 | 242 | [[providers.google.accounts]] |
| 244 | 243 | id = "personal" |
| 245 | -client_id = "GOOGLE_CLIENT_ID" | |
| 246 | -# client_secret = "GOOGLE_CLIENT_SECRET" | |
| 244 | +# client_id = "GOOGLE_CLIENT_ID" # optional custom OAuth client | |
| 245 | +# client_secret = "GOOGLE_CLIENT_SECRET" # optional custom OAuth client secret | |
| 247 | 246 | redirect_port = 8766 |
| 248 | 247 | calendars = ["primary"] |
| 249 | 248 | ``` |
| 250 | 249 | |
| 250 | +Advanced custom-client setup remains available for development builds or for | |
| 251 | +users who want their own Google OAuth quota/project: | |
| 252 | + | |
| 253 | +```sh | |
| 254 | +rcal providers google setup \ | |
| 255 | + --account personal \ | |
| 256 | + --client-id GOOGLE_CLIENT_ID \ | |
| 257 | + --client-secret GOOGLE_CLIENT_SECRET | |
| 258 | +``` | |
| 259 | + | |
| 251 | 260 | The Google provider syncs configured calendars through the Calendar API, |
| 252 | 261 | caches selected events separately from the local events JSON, and routes |
| 253 | 262 | create/edit/delete/copy operations for Google events back through Google |
@@ -259,8 +268,9 @@ Calendar. Provider reminders fire from cached Google events after a sync. | ||
| 259 | 268 | - CalDAV and other non-Microsoft/non-Google providers are not implemented yet. |
| 260 | 269 | - Provider sync is manual and cache-first; there is no background provider |
| 261 | 270 | sync daemon yet. |
| 262 | -- Google currently requires a user-supplied OAuth Desktop client ID; rcal does | |
| 263 | - not yet ship an official Google OAuth client. | |
| 271 | +- Google official OAuth client registration and verification are still in | |
| 272 | + progress; until those constants are filled for a release build, use the | |
| 273 | + advanced custom-client setup above. | |
| 264 | 274 | |
| 265 | 275 | ## Development |
| 266 | 276 | |
src/cli.rsmodified@@ -67,7 +67,7 @@ const HELP: &str = concat!( | ||
| 67 | 67 | " rcal providers google auth logout --account ID\n", |
| 68 | 68 | " rcal providers google auth inspect --account ID\n", |
| 69 | 69 | " rcal providers google calendars list --account ID\n", |
| 70 | - " rcal providers google setup --account ID --client-id ID [--client-secret SECRET] [--calendar ID]\n", | |
| 70 | + " rcal providers google setup --account ID [--client-id ID] [--client-secret SECRET] [--calendar ID]\n", | |
| 71 | 71 | " rcal providers google sync [--account ID]\n", |
| 72 | 72 | " rcal providers google status\n\n", |
| 73 | 73 | " rcal reminders run [--events-file PATH] [--state-file PATH] [--once]\n", |
@@ -195,7 +195,7 @@ pub enum MicrosoftCliAction { | ||
| 195 | 195 | pub enum GoogleCliAction { |
| 196 | 196 | Setup { |
| 197 | 197 | account: String, |
| 198 | - client_id: String, | |
| 198 | + client_id: Option<String>, | |
| 199 | 199 | client_secret: Option<String>, |
| 200 | 200 | calendar: Option<String>, |
| 201 | 201 | config_path: PathBuf, |
@@ -1210,7 +1210,7 @@ where | ||
| 1210 | 1210 | Ok(CliAction::Providers(ProviderCliAction::Google( |
| 1211 | 1211 | GoogleCliAction::Setup { |
| 1212 | 1212 | account: account.ok_or(CliError::MissingProviderAccount)?, |
| 1213 | - client_id: client_id.ok_or(CliError::MissingProviderClientId)?, | |
| 1213 | + client_id, | |
| 1214 | 1214 | client_secret, |
| 1215 | 1215 | calendar, |
| 1216 | 1216 | config_path, |
@@ -1816,8 +1816,20 @@ fn run_google_action( | ||
| 1816 | 1816 | config_path, |
| 1817 | 1817 | config, |
| 1818 | 1818 | } => (|| { |
| 1819 | - let account_config = | |
| 1820 | - GoogleAccountConfig::new(account.clone(), client_id.clone(), client_secret.clone()); | |
| 1819 | + let account_config = match &client_id { | |
| 1820 | + Some(client_id) => GoogleAccountConfig::new( | |
| 1821 | + account.clone(), | |
| 1822 | + client_id.clone(), | |
| 1823 | + client_secret.clone(), | |
| 1824 | + ), | |
| 1825 | + None => { | |
| 1826 | + let mut account_config = GoogleAccountConfig::new_official(account.clone())?; | |
| 1827 | + if client_secret.is_some() { | |
| 1828 | + account_config.client_secret = client_secret.clone(); | |
| 1829 | + } | |
| 1830 | + account_config | |
| 1831 | + } | |
| 1832 | + }; | |
| 1821 | 1833 | login_google_browser(&account_config, &http, &token_store, stdout)?; |
| 1822 | 1834 | let calendars = list_google_calendars(&account_config, &http, &token_store)?; |
| 1823 | 1835 | let calendar = choose_google_setup_calendar(&calendars, calendar.as_deref())?; |
@@ -3165,7 +3177,7 @@ create_event = ["n"] | ||
| 3165 | 3177 | action, |
| 3166 | 3178 | CliAction::Providers(ProviderCliAction::Google(GoogleCliAction::Setup { |
| 3167 | 3179 | account: "personal".to_string(), |
| 3168 | - client_id: "google-client".to_string(), | |
| 3180 | + client_id: Some("google-client".to_string()), | |
| 3169 | 3181 | client_secret: Some("google-secret".to_string()), |
| 3170 | 3182 | calendar: Some("primary".to_string()), |
| 3171 | 3183 | config_path, |
@@ -3174,6 +3186,40 @@ create_event = ["n"] | ||
| 3174 | 3186 | ); |
| 3175 | 3187 | } |
| 3176 | 3188 | |
| 3189 | + #[test] | |
| 3190 | + fn google_setup_command_allows_official_client_defaults() { | |
| 3191 | + let today = date(2026, Month::April, 23); | |
| 3192 | + let user_config = UserConfig::empty(); | |
| 3193 | + let google = user_config.providers.google.clone(); | |
| 3194 | + let config_path = PathBuf::from("/tmp/rcal/google-official-setup-config.toml"); | |
| 3195 | + | |
| 3196 | + let action = parse_args_with_config( | |
| 3197 | + [ | |
| 3198 | + arg("providers"), | |
| 3199 | + arg("google"), | |
| 3200 | + arg("setup"), | |
| 3201 | + arg("--account"), | |
| 3202 | + arg("personal"), | |
| 3203 | + ], | |
| 3204 | + today.into(), | |
| 3205 | + user_config, | |
| 3206 | + Some(config_path.clone()), | |
| 3207 | + ) | |
| 3208 | + .expect("setup parses"); | |
| 3209 | + | |
| 3210 | + assert_eq!( | |
| 3211 | + action, | |
| 3212 | + CliAction::Providers(ProviderCliAction::Google(GoogleCliAction::Setup { | |
| 3213 | + account: "personal".to_string(), | |
| 3214 | + client_id: None, | |
| 3215 | + client_secret: None, | |
| 3216 | + calendar: None, | |
| 3217 | + config_path, | |
| 3218 | + config: google, | |
| 3219 | + })) | |
| 3220 | + ); | |
| 3221 | + } | |
| 3222 | + | |
| 3177 | 3223 | #[test] |
| 3178 | 3224 | fn microsoft_setup_command_parses_with_calendar_and_config_path() { |
| 3179 | 3225 | let today = date(2026, Month::April, 23); |
src/config.rsmodified@@ -12,7 +12,7 @@ use crate::{ | ||
| 12 | 12 | providers::{ |
| 13 | 13 | GoogleAccountConfig, GoogleProviderConfig, MICROSOFT_DEFAULT_TENANT, |
| 14 | 14 | MICROSOFT_OFFICIAL_CLIENT_ID, MicrosoftAccountConfig, MicrosoftProviderConfig, |
| 15 | - ProviderConfig, ProviderCreateTarget, | |
| 15 | + ProviderConfig, ProviderCreateTarget, google_official_client_config, | |
| 16 | 16 | }, |
| 17 | 17 | }; |
| 18 | 18 | |
@@ -56,9 +56,8 @@ calendars = [] | ||
| 56 | 56 | |
| 57 | 57 | [providers.google] |
| 58 | 58 | # Google Calendar provider. |
| 59 | -# For now, create a Google OAuth Desktop client and paste its client_id here. | |
| 60 | -# Some Google clients also provide a client_secret; include it in the account | |
| 61 | -# block if token exchange requires it. | |
| 59 | +# rcal release builds can ship an official Google OAuth Desktop client. | |
| 60 | +# Advanced users may override client_id/client_secret inside an account block. | |
| 62 | 61 | enabled = false |
| 63 | 62 | default_account = "personal" |
| 64 | 63 | sync_past_days = 30 |
@@ -68,8 +67,8 @@ sync_future_days = 365 | ||
| 68 | 67 | |
| 69 | 68 | [[providers.google.accounts]] |
| 70 | 69 | id = "personal" |
| 71 | -client_id = "" | |
| 72 | -# client_secret = "" | |
| 70 | +# client_id = "GOOGLE_CLIENT_ID" | |
| 71 | +# client_secret = "GOOGLE_CLIENT_SECRET" | |
| 73 | 72 | redirect_port = 8766 |
| 74 | 73 | calendars = [] |
| 75 | 74 | |
@@ -215,7 +214,7 @@ pub struct MicrosoftSetupConfig { | ||
| 215 | 214 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 216 | 215 | pub struct GoogleSetupConfig { |
| 217 | 216 | pub account_id: String, |
| 218 | - pub client_id: String, | |
| 217 | + pub client_id: Option<String>, | |
| 219 | 218 | pub client_secret: Option<String>, |
| 220 | 219 | pub calendar_id: String, |
| 221 | 220 | pub calendar_name: String, |
@@ -360,14 +359,15 @@ fn google_setup_config_body(existing: &str, setup: &GoogleSetupConfig) -> String | ||
| 360 | 359 | "sync_future_days = {sync_future_days}\n\n", |
| 361 | 360 | "[[providers.google.accounts]]\n", |
| 362 | 361 | "id = {account}\n", |
| 363 | - "client_id = {client_id}\n", | |
| 364 | 362 | ), |
| 365 | 363 | account = toml_string(&setup.account_id), |
| 366 | 364 | calendar = toml_string(&setup.calendar_id), |
| 367 | - client_id = toml_string(&setup.client_id), | |
| 368 | 365 | sync_past_days = setup.sync_past_days.max(0), |
| 369 | 366 | sync_future_days = setup.sync_future_days.max(1), |
| 370 | 367 | )); |
| 368 | + if let Some(client_id) = &setup.client_id { | |
| 369 | + body.push_str(&format!("client_id = {}\n", toml_string(client_id))); | |
| 370 | + } | |
| 371 | 371 | if let Some(client_secret) = &setup.client_secret { |
| 372 | 372 | body.push_str(&format!("client_secret = {}\n", toml_string(client_secret))); |
| 373 | 373 | } |
@@ -620,7 +620,7 @@ struct RawGoogleProviderConfig { | ||
| 620 | 620 | #[serde(deny_unknown_fields)] |
| 621 | 621 | struct RawGoogleAccountConfig { |
| 622 | 622 | id: String, |
| 623 | - client_id: String, | |
| 623 | + client_id: Option<String>, | |
| 624 | 624 | client_secret: Option<String>, |
| 625 | 625 | redirect_port: Option<u16>, |
| 626 | 626 | calendars: Option<Vec<String>>, |
@@ -799,10 +799,18 @@ impl RawGoogleProviderConfig { | ||
| 799 | 799 | |
| 800 | 800 | impl RawGoogleAccountConfig { |
| 801 | 801 | fn into_config(self) -> GoogleAccountConfig { |
| 802 | + let uses_official_client = self.client_id.is_none(); | |
| 803 | + let official_client = uses_official_client | |
| 804 | + .then(google_official_client_config) | |
| 805 | + .flatten(); | |
| 806 | + let official_client_id = official_client | |
| 807 | + .as_ref() | |
| 808 | + .map(|(client_id, _)| client_id.clone()); | |
| 809 | + let official_client_secret = official_client.and_then(|(_, client_secret)| client_secret); | |
| 802 | 810 | GoogleAccountConfig { |
| 803 | 811 | id: self.id, |
| 804 | - client_id: self.client_id, | |
| 805 | - client_secret: self.client_secret, | |
| 812 | + client_id: self.client_id.or(official_client_id).unwrap_or_default(), | |
| 813 | + client_secret: self.client_secret.or(official_client_secret), | |
| 806 | 814 | redirect_port: self.redirect_port.unwrap_or(8766), |
| 807 | 815 | calendars: self.calendars.unwrap_or_default(), |
| 808 | 816 | } |
@@ -1176,7 +1184,7 @@ enabled = false | ||
| 1176 | 1184 | Some(path.clone()), |
| 1177 | 1185 | &GoogleSetupConfig { |
| 1178 | 1186 | account_id: "personal".to_string(), |
| 1179 | - client_id: "google-client".to_string(), | |
| 1187 | + client_id: Some("google-client".to_string()), | |
| 1180 | 1188 | client_secret: Some("google-secret".to_string()), |
| 1181 | 1189 | calendar_id: "primary".to_string(), |
| 1182 | 1190 | calendar_name: "Calendar".to_string(), |
src/providers.rsmodified@@ -36,10 +36,16 @@ const KEYRING_SERVICE: &str = "rcal.microsoft"; | ||
| 36 | 36 | const GOOGLE_CALENDAR_BASE_URL: &str = "https://www.googleapis.com/calendar/v3"; |
| 37 | 37 | const GOOGLE_AUTH_URL: &str = "https://accounts.google.com/o/oauth2/v2/auth"; |
| 38 | 38 | const GOOGLE_TOKEN_URL: &str = "https://oauth2.googleapis.com/token"; |
| 39 | -const GOOGLE_SCOPES: &str = "https://www.googleapis.com/auth/calendar"; | |
| 39 | +const GOOGLE_SCOPES: &str = concat!( | |
| 40 | + "https://www.googleapis.com/auth/calendar.calendarlist.readonly", | |
| 41 | + " ", | |
| 42 | + "https://www.googleapis.com/auth/calendar.events" | |
| 43 | +); | |
| 40 | 44 | const GOOGLE_KEYRING_SERVICE: &str = "rcal.google"; |
| 41 | 45 | pub const MICROSOFT_OFFICIAL_CLIENT_ID: &str = "9a49eaac-422b-4192-a65d-82dc8f43c11d"; |
| 42 | 46 | pub const MICROSOFT_DEFAULT_TENANT: &str = "common"; |
| 47 | +pub const GOOGLE_OFFICIAL_CLIENT_ID: &str = ""; | |
| 48 | +pub const GOOGLE_OFFICIAL_CLIENT_SECRET: &str = ""; | |
| 43 | 49 | |
| 44 | 50 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 45 | 51 | pub struct ProviderConfig { |
@@ -360,11 +366,30 @@ impl GoogleAccountConfig { | ||
| 360 | 366 | } |
| 361 | 367 | } |
| 362 | 368 | |
| 369 | + pub fn new_official(id: impl Into<String>) -> Result<Self, ProviderError> { | |
| 370 | + let Some((client_id, client_secret)) = google_official_client_config() else { | |
| 371 | + return Err(ProviderError::Config( | |
| 372 | + "this rcal build does not include an official Google OAuth client yet; pass --client-id and --client-secret or finish the official Google client registration".to_string(), | |
| 373 | + )); | |
| 374 | + }; | |
| 375 | + Ok(Self::new(id, client_id, client_secret)) | |
| 376 | + } | |
| 377 | + | |
| 363 | 378 | fn redirect_uri(&self) -> String { |
| 364 | 379 | format!("http://127.0.0.1:{}/callback", self.redirect_port) |
| 365 | 380 | } |
| 366 | 381 | } |
| 367 | 382 | |
| 383 | +pub fn google_official_client_config() -> Option<(String, Option<String>)> { | |
| 384 | + let client_id = GOOGLE_OFFICIAL_CLIENT_ID.trim(); | |
| 385 | + if client_id.is_empty() { | |
| 386 | + return None; | |
| 387 | + } | |
| 388 | + let client_secret = (!GOOGLE_OFFICIAL_CLIENT_SECRET.trim().is_empty()) | |
| 389 | + .then(|| GOOGLE_OFFICIAL_CLIENT_SECRET.to_string()); | |
| 390 | + Some((GOOGLE_OFFICIAL_CLIENT_ID.to_string(), client_secret)) | |
| 391 | +} | |
| 392 | + | |
| 368 | 393 | pub fn default_microsoft_cache_file() -> PathBuf { |
| 369 | 394 | if let Some(cache_home) = env::var_os("XDG_CACHE_HOME") { |
| 370 | 395 | return PathBuf::from(cache_home) |