//! 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, /// 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, /// 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, } 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 { let Ok(entries) = std::fs::read_dir(state_dir.join("google").join("accounts")) else { return Vec::new(); }; let mut accounts: Vec = 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 { 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 { 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 { 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 { 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 { 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 { 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 { "

calcalist is authorised

You can close this tab.

" } else { "

Authorisation failed

Check the terminal for details.

" }; 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, #[serde(default = "default_expiry")] expires_in: u64, } fn default_expiry() -> u64 { 3600 } fn post_token(form: &[(&str, &str)]) -> Result { let body = form .iter() .map(|(key, value)| format!("{}={}", encode(key), encode(value))) .collect::>() .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, 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 { 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 { 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:?}" ); } }