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:
parent
675d1a9397
commit
9f14c1f651
10 changed files with 1524 additions and 18 deletions
|
|
@ -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!(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue