Add Google OAuth, and let feed URLs come from a secret command

Google requires OAuth for calendar access; app passwords stopped working for
CalDAV, CardDAV and IMAP in March 2025, so there is no simpler path to offer.

- Authorisation code flow over a loopback redirect, which is what Google
  supports for desktop clients now the copy-paste flow is gone, with PKCE so an
  intercepted code is useless without the verifier. Only the refresh token is
  persisted, 0600, in the state directory.
- An expired grant is reported as itself: a consent screen still in Testing has
  its refresh tokens expired after 7 days, and "run calcalist google login" is
  more use than Google's bare invalid_grant.
- doctor reports whether each Google endpoint is still authorised, since an
  installation that worked last week can stop with nothing having changed here.

A webcal URL may now come from a command instead of the config. Google's secret
iCal address grants read access to a whole calendar to anyone holding it, so
writing it into a file described as portable and secret-free was a contradiction.

Fixed a serious defect in the first draft of this module: random_token used
fs::read on /dev/urandom, which reads to end of file. /dev/urandom has no end,
so it allocated until the machine ran out of memory — it took the editor down
with it. It now reads exactly 32 bytes, and a randomness failure is fatal rather
than falling back to the clock, since a guessable state or PKCE verifier defeats
the point of having them.

Verified end to end against a live Posteo CalDAV calendar: pimsync validated the
generated config against the real server, 58 events from a public feed were
mirrored and pushed, and a second run was a no-op.

97 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
randogoth 2026-09-10 12:40:47 +03:00
parent 675d1a9397
commit 9f14c1f651
10 changed files with 1524 additions and 18 deletions

View file

@ -130,7 +130,13 @@ pub fn generate(config: &Config, state_dir: &Path) -> Result<String, PimsyncErro
for endpoint in &config.endpoints {
match &endpoint.kind {
EndpointKind::Caldav { .. } => out.push_str(&caldav_pair(endpoint)?),
EndpointKind::Webcal { url } => out.push_str(&webcal_pair(endpoint, url)),
EndpointKind::Webcal { url, url_command } => {
out.push_str(&webcal_pair(
endpoint,
url.as_deref(),
url_command.as_deref(),
));
}
// Handled by calcalist's own Google module.
EndpointKind::Google { .. } => {}
}
@ -178,18 +184,24 @@ fn caldav_pair(endpoint: &Endpoint) -> Result<String, PimsyncError> {
Ok(block)
}
fn webcal_pair(endpoint: &Endpoint, url: &str) -> String {
fn webcal_pair(endpoint: &Endpoint, url: Option<&str>, url_command: Option<&str>) -> String {
let id = &endpoint.id;
// A feed URL that is itself a credential is fetched by command, the same way
// passwords are, so it never appears in either configuration file.
let url_directive = match (url, url_command) {
(_, Some(command)) => format!("\turl {{\n\t\tshell {command}\n\t}}\n"),
(Some(url), None) => format!("\turl {}\n", quote(url)),
(None, None) => String::new(),
};
// No `read_only` directive here: pimsync 0.5.11 documents it as applying to
// every storage type, but rejects it on a webcal storage with a bare "Could
// not parse file". It is redundant regardless — webcal is read-only by type,
// and `one_way` already fixes the direction.
format!(
"\nstorage {id}_remote {{\n\ttype webcal\n\turl {}\n\tcollection_id {id}\n}}\n\
"\nstorage {id}_remote {{\n\ttype webcal\n{url_directive}\tcollection_id {id}\n}}\n\
\npair {id} {{\n\tstorage_a {id}_remote\n\tstorage_b {LOCAL_STORAGE}\n\
\tcollection {{\n\t\talias {id}\n\t\tid_a {id}\n\t\tid_b {id}\n\t}}\n\
\tone_way\n\ton_empty skip\n\ton_delete skip\n}}\n",
quote(url)
\tone_way\n\ton_empty skip\n\ton_delete skip\n}}\n"
)
}
@ -377,6 +389,28 @@ url = "https://example.org/holidays.ics"
assert_eq!(text.matches("on_delete skip").count(), pairs);
}
/// A feed whose URL is itself a credential — Google's secret iCal address —
/// must be fetched by command, never written into either config file.
#[test]
fn a_secret_feed_url_is_fetched_by_command() {
let config: Config = toml::from_str(
r#"
version = 1
[[endpoint]]
id = "gcal-feed"
type = "webcal"
url_command = "secret-tool lookup service calcalist account gcal-ics"
"#,
)
.expect("config should parse");
let text = generate(&config, Path::new("/var/state/calcalist")).expect("generate");
assert!(text.contains("url {"));
assert!(text.contains("shell secret-tool lookup service calcalist account gcal-ics"));
assert!(!text.contains("https://"), "no literal URL should appear");
}
#[test]
fn splits_a_calendar_url_into_container_and_path() {
assert_eq!(