CalCalist/src/google/auth.rs

606 lines
21 KiB
Rust
Raw Normal View History

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>
2026-09-10 12:40:47 +03:00
//! OAuth 2.0 for an installed application.
//!
//! Google's Calendar API requires OAuth; there is no password to hand over. The
//! authorisation code is collected over a loopback redirect, which is the flow
//! Google supports for desktop clients since the copy-paste flow was withdrawn.
//! PKCE is used as well, so a code intercepted on the loopback interface is
//! useless without the verifier.
//!
//! Only the refresh token is persisted, in the state directory with owner-only
//! permissions. It never enters the portable configuration.
use std::fs;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream};
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use thiserror::Error;
const AUTH_ENDPOINT: &str = "https://accounts.google.com/o/oauth2/v2/auth";
const TOKEN_ENDPOINT: &str = "https://oauth2.googleapis.com/token";
/// Read and write access to calendars. Google classes this as sensitive, which
/// is why the consent screen needs publishing before refresh tokens stop
/// expiring after seven days.
pub const SCOPE: &str = "https://www.googleapis.com/auth/calendar";
/// Refresh a little early, so a token cannot expire mid-cycle.
const EXPIRY_MARGIN: Duration = Duration::from_secs(60);
#[derive(Debug, Error)]
pub enum AuthError {
#[error("could not run the secret command `{command}`: {source}")]
SecretCommand {
command: String,
#[source]
source: std::io::Error,
},
#[error("the secret command `{command}` failed: {message}")]
SecretFailed { command: String, message: String },
#[error("endpoint `{0}` has no client_secret_command, which Google's OAuth flow requires")]
NoClientSecret(String),
#[error("no endpoint named `{0}` in the configuration")]
UnknownEndpoint(String),
#[error("endpoint `{endpoint}` is a {kind} endpoint, not a Google one")]
NotGoogle { endpoint: String, kind: String },
#[error("the client_secret_command for `{0}` produced nothing")]
EmptyClientSecret(String),
#[error("could not listen on a loopback port: {0}")]
Listen(#[source] std::io::Error),
#[error("could not read randomness from /dev/urandom: {0}")]
Randomness(#[source] std::io::Error),
#[error("the browser did not complete authorisation: {0}")]
Redirect(String),
#[error("authorisation was refused: {0}")]
Denied(String),
#[error("the redirect did not match the request; authorisation was abandoned")]
StateMismatch,
#[error("token request failed: {0}")]
Token(String),
#[error(
"the stored authorisation for `{endpoint}` is no longer accepted by Google. \
An app whose consent screen is still in Testing has its refresh tokens expired \
after 7 days. Run `calcalist google login {endpoint}` to authorise again."
)]
Expired { endpoint: String },
#[error(
"Google returned no refresh token; re-run after removing calcalist from your account's third-party access, so consent is asked for again"
)]
NoRefreshToken,
#[error("endpoint `{0}` has not been authorised; run `calcalist google login {0}`")]
NotAuthorised(String),
#[error("could not access {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("could not parse the stored token at {path}: {source}")]
Parse {
path: PathBuf,
#[source]
source: serde_json::Error,
},
}
/// What is persisted between runs. The access token is cached only to avoid a
/// refresh on every command; the refresh token is the durable credential.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredToken {
pub refresh_token: String,
#[serde(default)]
pub access_token: Option<String>,
/// Unix seconds at which `access_token` stops being usable.
#[serde(default)]
pub expires_at: u64,
}
impl StoredToken {
fn usable_access_token(&self) -> Option<&str> {
let now = unix_now() + EXPIRY_MARGIN.as_secs();
self.access_token
.as_deref()
.filter(|_| self.expires_at > now)
}
}
/// Where an endpoint's refresh token lives.
pub fn token_path(state_dir: &Path, endpoint_id: &str) -> PathBuf {
state_dir.join("google").join(format!("{endpoint_id}.json"))
}
/// Runs an external command and returns its first line of output.
///
/// Secrets are fetched this way rather than stored, so the configuration file
/// stays portable and free of credentials.
pub fn run_secret_command(command: &str) -> Result<String, AuthError> {
let output = Command::new("sh")
.arg("-c")
.arg(command)
.output()
.map_err(|source| AuthError::SecretCommand {
command: command.to_string(),
source,
})?;
if !output.status.success() {
return Err(AuthError::SecretFailed {
command: command.to_string(),
message: String::from_utf8_lossy(&output.stderr).trim().to_string(),
});
}
let text = String::from_utf8_lossy(&output.stdout);
Ok(text.lines().next().unwrap_or_default().trim().to_string())
}
/// Everything needed to talk to Google for one endpoint.
#[derive(Debug, Clone)]
pub struct Credentials {
pub client_id: String,
pub client_secret: String,
}
/// Resolves an endpoint's OAuth client, running its secret command to fetch the
/// client secret. The secret is never written to the configuration.
pub fn credentials_for(
config: &crate::config::Config,
endpoint_id: &str,
) -> Result<Credentials, AuthError> {
let endpoint = config
.endpoint(endpoint_id)
.ok_or_else(|| AuthError::UnknownEndpoint(endpoint_id.to_string()))?;
let crate::config::EndpointKind::Google {
client_id,
client_secret_command,
..
} = &endpoint.kind
else {
return Err(AuthError::NotGoogle {
endpoint: endpoint_id.to_string(),
kind: endpoint.kind.kind_name().to_string(),
});
};
let command = client_secret_command
.as_deref()
.ok_or_else(|| AuthError::NoClientSecret(endpoint_id.to_string()))?;
let client_secret = run_secret_command(command)?;
if client_secret.is_empty() {
return Err(AuthError::EmptyClientSecret(endpoint_id.to_string()));
}
Ok(Credentials {
client_id: client_id.clone(),
client_secret,
})
}
/// Obtains an access token, refreshing the stored one when it has expired.
pub fn access_token(
state_dir: &Path,
endpoint_id: &str,
credentials: &Credentials,
) -> Result<String, AuthError> {
let path = token_path(state_dir, endpoint_id);
let mut stored =
load_token(&path)?.ok_or_else(|| AuthError::NotAuthorised(endpoint_id.to_string()))?;
if let Some(token) = stored.usable_access_token() {
return Ok(token.to_string());
}
let refreshed = post_token(&[
("client_id", &credentials.client_id),
("client_secret", &credentials.client_secret),
("refresh_token", &stored.refresh_token),
("grant_type", "refresh_token"),
])
.map_err(|error| match error {
AuthError::Token(message) if message.contains("invalid_grant") => AuthError::Expired {
endpoint: endpoint_id.to_string(),
},
other => other,
})?;
stored.access_token = Some(refreshed.access_token.clone());
stored.expires_at = unix_now() + refreshed.expires_in;
save_token(&path, &stored)?;
Ok(refreshed.access_token)
}
/// Walks the user through authorisation and stores the resulting refresh token.
pub fn login(
state_dir: &Path,
endpoint_id: &str,
credentials: &Credentials,
) -> Result<(), AuthError> {
let listener =
TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))).map_err(AuthError::Listen)?;
let port = listener.local_addr().map_err(AuthError::Listen)?.port();
let redirect_uri = format!("http://127.0.0.1:{port}");
let state = random_token()?;
let verifier = random_token()?;
let challenge = pkce_challenge(&verifier);
let url = format!(
"{AUTH_ENDPOINT}?client_id={}&redirect_uri={}&response_type=code&scope={}\
&access_type=offline&prompt=consent&state={}&code_challenge={}&code_challenge_method=S256",
encode(&credentials.client_id),
encode(&redirect_uri),
encode(SCOPE),
encode(&state),
encode(&challenge),
);
println!("Open this URL to authorise calcalist:\n\n{url}\n");
open_in_browser(&url);
println!("Waiting for the redirect on {redirect_uri} ...");
let code = wait_for_code(&listener, &state)?;
let issued = post_token(&[
("code", &code),
("client_id", &credentials.client_id),
("client_secret", &credentials.client_secret),
("redirect_uri", &redirect_uri),
("grant_type", "authorization_code"),
("code_verifier", &verifier),
])?;
let refresh_token = issued.refresh_token.ok_or(AuthError::NoRefreshToken)?;
let path = token_path(state_dir, endpoint_id);
save_token(
&path,
&StoredToken {
refresh_token,
access_token: Some(issued.access_token),
expires_at: unix_now() + issued.expires_in,
},
)?;
println!("Authorised. Refresh token stored at {}", path.display());
Ok(())
}
/// Accepts one loopback request and extracts the authorisation code from it.
fn wait_for_code(listener: &TcpListener, expected_state: &str) -> Result<String, AuthError> {
let (mut stream, _) = listener.accept().map_err(AuthError::Listen)?;
let target = read_request_target(&stream)?;
let query = target.split_once('?').map(|(_, query)| query).unwrap_or("");
let params = parse_query(query);
let outcome = match (params.get("code"), params.get("error"), params.get("state")) {
(_, Some(error), _) => Err(AuthError::Denied(error.clone())),
(Some(_), _, state) if state.map(String::as_str) != Some(expected_state) => {
Err(AuthError::StateMismatch)
}
(Some(code), _, _) => Ok(code.clone()),
(None, None, _) => Err(AuthError::Redirect(
"the redirect carried neither a code nor an error".into(),
)),
};
respond(&mut stream, outcome.is_ok());
outcome
}
fn read_request_target(stream: &TcpStream) -> Result<String, AuthError> {
let mut line = String::new();
BufReader::new(stream)
.read_line(&mut line)
.map_err(|error| AuthError::Redirect(error.to_string()))?;
line.split_whitespace()
.nth(1)
.map(str::to_string)
.ok_or_else(|| AuthError::Redirect(format!("could not parse request line {line:?}")))
}
fn respond(stream: &mut TcpStream, success: bool) {
let body = if success {
"<h1>calcalist is authorised</h1><p>You can close this tab.</p>"
} else {
"<h1>Authorisation failed</h1><p>Check the terminal for details.</p>"
};
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
// The browser tab is a courtesy; failing to write to it changes nothing.
let _ = stream.write_all(response.as_bytes());
}
#[derive(Debug, Deserialize)]
struct IssuedToken {
access_token: String,
#[serde(default)]
refresh_token: Option<String>,
#[serde(default = "default_expiry")]
expires_in: u64,
}
fn default_expiry() -> u64 {
3600
}
fn post_token(form: &[(&str, &str)]) -> Result<IssuedToken, AuthError> {
let body = form
.iter()
.map(|(key, value)| format!("{}={}", encode(key), encode(value)))
.collect::<Vec<_>>()
.join("&");
let agent: ureq::Agent = ureq::Agent::config_builder()
.http_status_as_error(false)
.build()
.into();
let mut response = agent
.post(TOKEN_ENDPOINT)
.content_type("application/x-www-form-urlencoded")
.send(&body)
.map_err(|error| AuthError::Token(error.to_string()))?;
let status = response.status();
let text = response
.body_mut()
.read_to_string()
.map_err(|error| AuthError::Token(error.to_string()))?;
if !status.is_success() {
return Err(AuthError::Token(format!("HTTP {status}: {text}")));
}
serde_json::from_str(&text).map_err(|error| AuthError::Token(error.to_string()))
}
fn load_token(path: &Path) -> Result<Option<StoredToken>, AuthError> {
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(source) => {
return Err(AuthError::Io {
path: path.to_path_buf(),
source,
});
}
};
serde_json::from_str(&text)
.map(Some)
.map_err(|source| AuthError::Parse {
path: path.to_path_buf(),
source,
})
}
/// Writes the token readable only by its owner. It is a durable credential, and
/// the state directory may not be private on every system.
fn save_token(path: &Path, token: &StoredToken) -> Result<(), AuthError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|source| AuthError::Io {
path: parent.to_path_buf(),
source,
})?;
let _ = fs::set_permissions(parent, fs::Permissions::from_mode(0o700));
}
let text = serde_json::to_string_pretty(token).map_err(|source| AuthError::Parse {
path: path.to_path_buf(),
source,
})?;
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.map_err(|source| AuthError::Io {
path: path.to_path_buf(),
source,
})?;
file.write_all(text.as_bytes())
.map_err(|source| AuthError::Io {
path: path.to_path_buf(),
source,
})
}
fn open_in_browser(url: &str) {
// Best effort: on a headless machine the printed URL is the real interface.
let _ = Command::new("xdg-open").arg(url).status();
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
/// 32 bytes of kernel randomness, base64url encoded.
///
/// Reads exactly 32 bytes. `/dev/urandom` is an endless stream, so anything that
/// reads it to completion — `fs::read`, for one — allocates until the machine
/// runs out of memory.
///
/// A failure here is fatal rather than papered over: both callers need
/// unpredictable values, and a guessable `state` or PKCE verifier would defeat
/// the protection they exist to provide.
fn random_token() -> Result<String, AuthError> {
let mut bytes = [0u8; 32];
fs::File::open("/dev/urandom")
.and_then(|mut file| file.read_exact(&mut bytes))
.map_err(AuthError::Randomness)?;
Ok(base64url(&bytes))
}
fn pkce_challenge(verifier: &str) -> String {
base64url(&Sha256::digest(verifier.as_bytes()))
}
/// Base64 with the URL alphabet and no padding, as RFC 7636 requires.
fn base64url(bytes: &[u8]) -> String {
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let bits = chunk.iter().enumerate().fold(0u32, |acc, (index, byte)| {
acc | (u32::from(*byte) << (16 - 8 * index))
});
// Three bytes make four characters; a short chunk makes proportionally fewer.
for index in 0..=chunk.len() {
let shift = 18 - 6 * index;
out.push(ALPHABET[((bits >> shift) & 0b11_1111) as usize] as char);
}
}
out
}
fn encode(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char);
}
_ => out.push_str(&format!("%{byte:02X}")),
}
}
out
}
fn parse_query(query: &str) -> std::collections::HashMap<String, String> {
query
.split('&')
.filter_map(|pair| pair.split_once('='))
.map(|(key, value)| (decode(key), decode(value)))
.collect()
}
fn decode(value: &str) -> String {
let mut out = Vec::with_capacity(value.len());
let bytes = value.as_bytes();
let mut index = 0;
while index < bytes.len() {
match bytes[index] {
b'%' if index + 2 < bytes.len() => {
let hex = std::str::from_utf8(&bytes[index + 1..index + 3]).unwrap_or("");
match u8::from_str_radix(hex, 16) {
Ok(byte) => {
out.push(byte);
index += 3;
}
Err(_) => {
out.push(bytes[index]);
index += 1;
}
}
}
b'+' => {
out.push(b' ');
index += 1;
}
byte => {
out.push(byte);
index += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
/// RFC 4648 section 10 test vectors, minus the padding RFC 7636 forbids.
#[test]
fn base64url_matches_the_specification() {
assert_eq!(base64url(b""), "");
assert_eq!(base64url(b"f"), "Zg");
assert_eq!(base64url(b"fo"), "Zm8");
assert_eq!(base64url(b"foo"), "Zm9v");
assert_eq!(base64url(b"foob"), "Zm9vYg");
assert_eq!(base64url(b"fooba"), "Zm9vYmE");
assert_eq!(base64url(b"foobar"), "Zm9vYmFy");
}
/// The URL alphabet must use - and _ rather than + and /, or Google rejects
/// the challenge.
#[test]
fn base64url_uses_the_url_alphabet() {
let encoded = base64url(&[0xfb, 0xff, 0xfe]);
assert!(!encoded.contains('+'), "{encoded}");
assert!(!encoded.contains('/'), "{encoded}");
assert!(!encoded.contains('='), "{encoded}");
}
/// RFC 7636 appendix B. Getting this wrong fails authorisation with an
/// unhelpful error, so it is pinned to the published vector.
#[test]
fn pkce_challenge_matches_rfc_7636() {
assert_eq!(
pkce_challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"),
"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
);
}
/// Regression: this previously read /dev/urandom to EOF, which allocates
/// without bound and takes the machine down with it.
#[test]
fn a_verifier_is_long_enough_and_url_safe() {
let verifier = random_token().expect("randomness should be available");
assert!(verifier.len() >= 43, "too short for RFC 7636: {verifier}");
assert!(
verifier
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_')),
"{verifier}"
);
assert_ne!(
verifier,
random_token().expect("randomness should be available"),
"tokens must not repeat"
);
}
#[test]
fn percent_encoding_round_trips() {
for value in ["plain", "with space", "a/b?c=d&e", "sömething", "100%"] {
assert_eq!(decode(&encode(value)), value, "{value}");
}
}
#[test]
fn reserved_characters_are_escaped() {
assert_eq!(encode("a/b?c=d&e"), "a%2Fb%3Fc%3Dd%26e");
assert_eq!(encode("a-b_c.d~e"), "a-b_c.d~e");
}
#[test]
fn a_redirect_query_is_parsed() {
let params = parse_query("code=4%2F0Ab&state=xyz&scope=https%3A%2F%2Fexample");
assert_eq!(params.get("code").map(String::as_str), Some("4/0Ab"));
assert_eq!(params.get("state").map(String::as_str), Some("xyz"));
assert_eq!(
params.get("scope").map(String::as_str),
Some("https://example")
);
}
#[test]
fn a_cached_access_token_is_reused_only_while_valid() {
let fresh = StoredToken {
refresh_token: "r".into(),
access_token: Some("a".into()),
expires_at: unix_now() + 3600,
};
assert_eq!(fresh.usable_access_token(), Some("a"));
let expired = StoredToken {
expires_at: unix_now(),
..fresh.clone()
};
assert_eq!(expired.usable_access_token(), None);
// Within the margin it is treated as expired, so it cannot lapse mid-cycle.
let nearly = StoredToken {
expires_at: unix_now() + 5,
..fresh
};
assert_eq!(nearly.usable_access_token(), None);
}
}