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
605
src/google/auth.rs
Normal file
605
src/google/auth.rs
Normal file
|
|
@ -0,0 +1,605 @@
|
|||
//! 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue