Eight items were still open at the end of M1: two integration tests that had only been run by hand, and six known gaps. Recurrence overrides now reach Google. Google addresses an exception through the series rather than as an event of its own, so the master is sent first and each override is then matched to its instance by original start time and patched. Matching needs the two sides' spellings reduced to one key: iCalendar writes a zoned local time, Google an absolute offset. An override matching no occurrence is counted rather than forced — that means a stale RECURRENCE-ID left behind by an edited RRULE, and inventing an event for it would put something in the calendar the series does not contain. Reading one component apart from another needed a view `properties` cannot give: it flattens every VEVENT together, which is right for the UID a series shares and wrong for an override, whose SUMMARY and the master's are then indistinguishable. `Calendar::events` splits them. A TZID now travels with the VTIMEZONE that defines it, derived from the zone's own transition table as the yearly rule it implies. This changes the content hash of every zoned recurring event, so the first cycle after this re-pushes them. A Google authorisation is filed under the account it was granted for rather than the endpoint that asked for it, so two endpoints on one account no longer need a login each. The account is read from the primary calendar's id, which needs no scope beyond the calendar one already granted. Authorisations written by the previous scheme are still honoured, and move across at the next login. An unreachable Google endpoint no longer ends the cycle — one lapsed token used to stop the CalDAV side too. It is named, only the aggregates depending on it stand down, and the run exits non-zero so a partial cycle cannot pass for success. The CalDAV leg cannot be narrowed the same way: pimsync is one process covering every pair, so a failure does not say which pair it belongs to. `--dry-run` now pulls for real, into a throwaway copy of the local mirrors and through a pimsync configuration that only ever reads from a server. What it reports is measured against the calendars as they are now rather than against whatever the last real cycle left behind. `calcalist prune` reports local mirrors of endpoints the configuration no longer names, and removes them under --force. The integration tests run against a real Radicale server and a real iCal feed: convergence and idempotence, the dry run, prune, and the scheduling rule asserted on the bytes that actually reached the server. The plan asked for an SMTP sink for that last one; Radicale implements no RFC 6638 scheduling, so a quiet SMTP port would have proved nothing about the transform. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
864 lines
31 KiB
Rust
864 lines
31 KiB
Rust
//! 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(
|
|
"endpoint `{endpoint}` names account `{account}`, which has not been authorised; \
|
|
run `calcalist google login {endpoint}`"
|
|
)]
|
|
UnknownAccount { endpoint: String, account: String },
|
|
#[error(
|
|
"endpoint `{endpoint}` does not say which Google account it belongs to, and calcalist \
|
|
is logged in to several ({accounts}). Add `account = \"…\"` to the endpoint."
|
|
)]
|
|
AmbiguousAccount { endpoint: String, accounts: String },
|
|
#[error(
|
|
"the authorisation stored for account `{account}` was issued to a different OAuth \
|
|
client than endpoint `{endpoint}` configures; run `calcalist google login {endpoint}`"
|
|
)]
|
|
ClientMismatch { endpoint: String, account: 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,
|
|
/// The account this authorisation belongs to. Absent in files written
|
|
/// before authorisations were shared between endpoints.
|
|
#[serde(default)]
|
|
pub account: Option<String>,
|
|
/// The OAuth client the refresh token was issued to. A refresh token is
|
|
/// only valid for the client that obtained it, so a changed `client_id`
|
|
/// has to be caught here rather than as an opaque `invalid_grant`.
|
|
#[serde(default)]
|
|
pub client_id: Option<String>,
|
|
}
|
|
|
|
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 authorisation lives, keyed by the account it was granted for.
|
|
///
|
|
/// An authorisation covers every calendar the account can see, so two endpoints
|
|
/// on one account share this file and only one of them ever has to log in.
|
|
pub fn account_token_path(state_dir: &Path, account: &str) -> PathBuf {
|
|
state_dir
|
|
.join("google")
|
|
.join("accounts")
|
|
.join(format!("{}.json", sanitise(account)))
|
|
}
|
|
|
|
/// Where an endpoint's refresh token used to live, before authorisations were
|
|
/// keyed by account. Still read, so an existing installation keeps working
|
|
/// until its next login moves it across.
|
|
pub fn legacy_token_path(state_dir: &Path, endpoint_id: &str) -> PathBuf {
|
|
state_dir.join("google").join(format!("{endpoint_id}.json"))
|
|
}
|
|
|
|
/// Keeps an account name to one harmless path segment.
|
|
fn sanitise(account: &str) -> String {
|
|
account
|
|
.chars()
|
|
.map(|ch| match ch {
|
|
'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | '@' => ch,
|
|
_ => '_',
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Every account calcalist currently holds an authorisation for.
|
|
pub fn authorised_accounts(state_dir: &Path) -> Vec<String> {
|
|
let Ok(entries) = std::fs::read_dir(state_dir.join("google").join("accounts")) else {
|
|
return Vec::new();
|
|
};
|
|
let mut accounts: Vec<String> = entries
|
|
.flatten()
|
|
.filter_map(|entry| load_token(&entry.path()).ok().flatten())
|
|
.filter_map(|token| token.account)
|
|
.collect();
|
|
accounts.sort();
|
|
accounts.dedup();
|
|
accounts
|
|
}
|
|
|
|
/// Picks the stored authorisation an endpoint should use.
|
|
///
|
|
/// The endpoint may name its account outright. Failing that, an authorisation
|
|
/// left over from the per-endpoint scheme is honoured, and then a single
|
|
/// account is taken to be the one meant — which is the ordinary case, and what
|
|
/// makes a second endpoint on the same account need no login of its own.
|
|
fn resolve_token(
|
|
state_dir: &Path,
|
|
endpoint_id: &str,
|
|
account: Option<&str>,
|
|
) -> Result<(PathBuf, StoredToken), AuthError> {
|
|
if let Some(account) = account {
|
|
let path = account_token_path(state_dir, account);
|
|
let stored = load_token(&path)?.ok_or_else(|| AuthError::UnknownAccount {
|
|
endpoint: endpoint_id.to_string(),
|
|
account: account.to_string(),
|
|
})?;
|
|
return Ok((path, stored));
|
|
}
|
|
|
|
let legacy = legacy_token_path(state_dir, endpoint_id);
|
|
if let Some(stored) = load_token(&legacy)? {
|
|
return Ok((legacy, stored));
|
|
}
|
|
|
|
let accounts = authorised_accounts(state_dir);
|
|
match accounts.as_slice() {
|
|
[] => Err(AuthError::NotAuthorised(endpoint_id.to_string())),
|
|
[only] => {
|
|
let path = account_token_path(state_dir, only);
|
|
let stored = load_token(&path)?.ok_or_else(|| AuthError::UnknownAccount {
|
|
endpoint: endpoint_id.to_string(),
|
|
account: only.clone(),
|
|
})?;
|
|
Ok((path, stored))
|
|
}
|
|
several => Err(AuthError::AmbiguousAccount {
|
|
endpoint: endpoint_id.to_string(),
|
|
accounts: several.join(", "),
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
account: Option<&str>,
|
|
credentials: &Credentials,
|
|
) -> Result<String, AuthError> {
|
|
let (path, mut stored) = resolve_token(state_dir, endpoint_id, account)?;
|
|
if let Some(issued_to) = &stored.client_id
|
|
&& *issued_to != credentials.client_id
|
|
{
|
|
return Err(AuthError::ClientMismatch {
|
|
endpoint: endpoint_id.to_string(),
|
|
account: stored.account.clone().unwrap_or_else(|| "?".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)?;
|
|
|
|
// Which account consented is not in the token response, but the primary
|
|
// calendar's id is the account's own address — and reading it needs nothing
|
|
// beyond the calendar scope already granted, unlike the userinfo endpoint.
|
|
let account = primary_calendar_id(&issued.access_token);
|
|
let path = match &account {
|
|
Some(account) => account_token_path(state_dir, account),
|
|
None => legacy_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,
|
|
account: account.clone(),
|
|
client_id: Some(credentials.client_id.clone()),
|
|
},
|
|
)?;
|
|
// The authorisation now lives under the account, so the copy this endpoint
|
|
// used to keep would only shadow it.
|
|
let legacy = legacy_token_path(state_dir, endpoint_id);
|
|
if account.is_some() && legacy.exists() {
|
|
let _ = std::fs::remove_file(&legacy);
|
|
}
|
|
match &account {
|
|
Some(account) => println!(
|
|
"Authorised {account}. Every endpoint on this account can use it; \
|
|
the refresh token is stored at {}",
|
|
path.display()
|
|
),
|
|
None => println!("Authorised. Refresh token stored at {}", path.display()),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// The address of the authorised account, read from its primary calendar.
|
|
///
|
|
/// Returned as an option rather than an error: failing to name the account only
|
|
/// costs the sharing, and the authorisation itself is perfectly good without it.
|
|
fn primary_calendar_id(access_token: &str) -> Option<String> {
|
|
let agent: ureq::Agent = ureq::Agent::config_builder()
|
|
.http_status_as_error(false)
|
|
.build()
|
|
.into();
|
|
let mut response = agent
|
|
.get("https://www.googleapis.com/calendar/v3/calendars/primary")
|
|
.header("Authorization", format!("Bearer {access_token}"))
|
|
.call()
|
|
.ok()?;
|
|
if !(200..300).contains(&response.status().as_u16()) {
|
|
return None;
|
|
}
|
|
let body = response.body_mut().read_to_string().ok()?;
|
|
let value: serde_json::Value = serde_json::from_str(&body).ok()?;
|
|
value
|
|
.get("id")
|
|
.and_then(|id| id.as_str())
|
|
.map(str::to_string)
|
|
}
|
|
|
|
/// 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,
|
|
account: None,
|
|
client_id: None,
|
|
};
|
|
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);
|
|
}
|
|
|
|
fn stored(account: &str, client_id: &str) -> StoredToken {
|
|
StoredToken {
|
|
refresh_token: format!("refresh-for-{account}"),
|
|
access_token: None,
|
|
expires_at: 0,
|
|
account: Some(account.to_string()),
|
|
client_id: Some(client_id.to_string()),
|
|
}
|
|
}
|
|
|
|
/// The gap this closes: two endpoints on one account needed a login each,
|
|
/// because the authorisation was filed under the endpoint rather than the
|
|
/// account it actually belongs to.
|
|
#[test]
|
|
fn one_authorisation_serves_every_endpoint_on_that_account() {
|
|
let dir = tempfile::tempdir().expect("temp");
|
|
save_token(
|
|
&account_token_path(dir.path(), "me@example.com"),
|
|
&stored("me@example.com", "client-a"),
|
|
)
|
|
.expect("save");
|
|
|
|
for endpoint in ["work", "personal", "never-logged-in"] {
|
|
let (_, token) = resolve_token(dir.path(), endpoint, None).expect("resolve");
|
|
assert_eq!(token.refresh_token, "refresh-for-me@example.com");
|
|
}
|
|
}
|
|
|
|
/// Two accounts cannot be guessed between, so the endpoint has to say.
|
|
#[test]
|
|
fn several_accounts_have_to_be_told_apart() {
|
|
let dir = tempfile::tempdir().expect("temp");
|
|
for account in ["one@example.com", "two@example.com"] {
|
|
save_token(
|
|
&account_token_path(dir.path(), account),
|
|
&stored(account, "client-a"),
|
|
)
|
|
.expect("save");
|
|
}
|
|
|
|
let error = resolve_token(dir.path(), "work", None).expect_err("ambiguous");
|
|
assert!(
|
|
matches!(error, AuthError::AmbiguousAccount { .. }),
|
|
"{error:?}"
|
|
);
|
|
assert!(error.to_string().contains("one@example.com"), "{error}");
|
|
|
|
let (_, token) =
|
|
resolve_token(dir.path(), "work", Some("two@example.com")).expect("resolve");
|
|
assert_eq!(token.refresh_token, "refresh-for-two@example.com");
|
|
}
|
|
|
|
/// An authorisation written by an earlier version keeps working untouched.
|
|
#[test]
|
|
fn an_authorisation_from_the_old_layout_is_still_honoured() {
|
|
let dir = tempfile::tempdir().expect("temp");
|
|
save_token(
|
|
&legacy_token_path(dir.path(), "work"),
|
|
&StoredToken {
|
|
refresh_token: "old".into(),
|
|
access_token: None,
|
|
expires_at: 0,
|
|
account: None,
|
|
client_id: None,
|
|
},
|
|
)
|
|
.expect("save");
|
|
|
|
let (_, token) = resolve_token(dir.path(), "work", None).expect("resolve");
|
|
assert_eq!(token.refresh_token, "old");
|
|
// A different endpoint has nothing to fall back on.
|
|
let error = resolve_token(dir.path(), "other", None).expect_err("not authorised");
|
|
assert!(matches!(error, AuthError::NotAuthorised(_)), "{error:?}");
|
|
}
|
|
|
|
/// A refresh token only works for the client it was issued to, so a changed
|
|
/// client_id has to say so rather than surface as an opaque invalid_grant.
|
|
#[test]
|
|
fn a_token_from_another_oauth_client_is_refused_by_name() {
|
|
let dir = tempfile::tempdir().expect("temp");
|
|
save_token(
|
|
&account_token_path(dir.path(), "me@example.com"),
|
|
&stored("me@example.com", "client-a"),
|
|
)
|
|
.expect("save");
|
|
|
|
let credentials = Credentials {
|
|
client_id: "client-b".into(),
|
|
client_secret: "secret".into(),
|
|
};
|
|
let error = access_token(dir.path(), "work", None, &credentials).expect_err("mismatch");
|
|
assert!(
|
|
matches!(error, AuthError::ClientMismatch { .. }),
|
|
"{error:?}"
|
|
);
|
|
}
|
|
}
|