606 lines
21 KiB
Rust
606 lines
21 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("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);
|
||
|
|
}
|
||
|
|
}
|