use std::fmt::Write as _;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::path::PathBuf;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use serde_json::Value;
const AUTHORIZE_URL: &str = "https://accounts.google.com/o/oauth2/v2/auth";
const TOKEN_URL: &str = "https://oauth2.googleapis.com/token";
const REVOKE_URL: &str = "https://oauth2.googleapis.com/revoke";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthContext {
Calendar,
GmailRead,
}
impl AuthContext {
const fn scopes(self) -> &'static [&'static str] {
match self {
Self::Calendar => &["https://www.googleapis.com/auth/calendar"],
Self::GmailRead => &["https://www.googleapis.com/auth/gmail.readonly"],
}
}
const fn token_filename(self) -> &'static str {
match self {
Self::Calendar => "google_oauth.json",
Self::GmailRead => "google_oauth_gmail_read.json",
}
}
pub const fn label(self) -> &'static str {
match self {
Self::Calendar => "calendar",
Self::GmailRead => "gmail-read",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_lowercase().as_str() {
"calendar" | "cal" | "gcal" => Some(Self::Calendar),
"gmail" | "gmail-read" | "gmail_read" | "mail" => Some(Self::GmailRead),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GoogleTokens {
pub access_token: String,
pub refresh_token: String,
pub expires_at: i64,
pub scope: String,
pub token_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ClientCreds {
client_id: String,
client_secret: String,
}
fn secrets_dir() -> PathBuf {
let home = std::env::var("USERPROFILE")
.or_else(|_| std::env::var("HOME"))
.unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join(".claudette").join("secrets")
}
fn tokens_path(ctx: AuthContext) -> PathBuf {
secrets_dir().join(ctx.token_filename())
}
fn client_path() -> PathBuf {
secrets_dir().join("google_oauth_client.json")
}
fn load_client_creds() -> Result<ClientCreds, String> {
let id = lookup_env_or_file("CLIENT_ID").ok();
let secret = lookup_env_or_file("CLIENT_SECRET").ok();
if let (Some(client_id), Some(client_secret)) = (id, secret) {
return Ok(ClientCreds {
client_id,
client_secret,
});
}
let path = client_path();
if path.exists() {
let raw = std::fs::read_to_string(&path)
.map_err(|e| format!("google_auth: read {}: {e}", path.display()))?;
let creds: ClientCreds = serde_json::from_str(&raw).map_err(|e| {
format!(
"google_auth: parse {}: {e}. Expected JSON with 'client_id' and 'client_secret'.",
path.display()
)
})?;
return Ok(creds);
}
Err(format!(
"google_auth: OAuth client not configured. Set CLAUDETTE_GOOGLE_CLIENT_ID + \
CLAUDETTE_GOOGLE_CLIENT_SECRET env vars, or write JSON {{\"client_id\":\"...\",\
\"client_secret\":\"...\"}} to {}. See docs/google_setup.md for how to create \
the OAuth client in Google Cloud Console.",
path.display()
))
}
fn lookup_env_or_file(suffix: &str) -> Result<String, ()> {
for var in [
format!("CLAUDETTE_GOOGLE_{suffix}"),
format!("GOOGLE_{suffix}"),
] {
if let Ok(val) = std::env::var(&var) {
let trimmed = val.trim();
if !trimmed.is_empty() {
return Ok(trimmed.to_string());
}
}
}
Err(())
}
fn save_tokens(ctx: AuthContext, tokens: &GoogleTokens) -> Result<(), String> {
let path = tokens_path(ctx);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("google_auth: create {}: {e}", parent.display()))?;
}
let body = serde_json::to_string_pretty(tokens)
.map_err(|e| format!("google_auth: serialize tokens: {e}"))?;
crate::secrets::write_secret_file(&path, body.as_bytes())
.map_err(|e| format!("google_auth: write {}: {e}", path.display()))?;
Ok(())
}
fn load_tokens(ctx: AuthContext) -> Result<GoogleTokens, String> {
let path = tokens_path(ctx);
if !path.exists() {
return Err(format!(
"google_auth: not authenticated for {}. Run `claudette --auth-google {}` first. \
(Expected tokens at {}.)",
ctx.label(),
ctx.label(),
path.display()
));
}
let raw = std::fs::read_to_string(&path)
.map_err(|e| format!("google_auth: read {}: {e}", path.display()))?;
serde_json::from_str(&raw).map_err(|e| format!("google_auth: parse {}: {e}", path.display()))
}
fn now_unix() -> i64 {
chrono::Utc::now().timestamp()
}
fn http_client() -> Result<reqwest::blocking::Client, String> {
reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| format!("google_auth: build http client: {e}"))
}
pub fn access_token(ctx: AuthContext) -> Result<String, String> {
let mut tokens = load_tokens(ctx)?;
if tokens.expires_at - now_unix() < 60 {
refresh_tokens(&mut tokens, Some(ctx))?;
save_tokens(ctx, &tokens)?;
}
Ok(tokens.access_token)
}
fn refresh_tokens(tokens: &mut GoogleTokens, ctx: Option<AuthContext>) -> Result<(), String> {
crate::egress::guard(TOKEN_URL)?;
let creds = load_client_creds()?;
let client = http_client()?;
let params = [
("client_id", creds.client_id.as_str()),
("client_secret", creds.client_secret.as_str()),
("refresh_token", tokens.refresh_token.as_str()),
("grant_type", "refresh_token"),
];
let resp = client
.post(TOKEN_URL)
.form(¶ms)
.send()
.map_err(|e| format!("google_auth: refresh request failed: {e}"))?;
let status = resp.status();
let body: Value = resp
.json()
.map_err(|e| format!("google_auth: refresh parse failed: {e}"))?;
if !status.is_success() {
return Err(classify_refresh_failure(status, &body, ctx));
}
let access = body
.get("access_token")
.and_then(Value::as_str)
.ok_or("google_auth: refresh response missing access_token")?;
let expires_in = body
.get("expires_in")
.and_then(Value::as_i64)
.unwrap_or(3600);
if let Some(scope) = body.get("scope").and_then(Value::as_str) {
tokens.scope = scope.to_string();
}
tokens.access_token = access.to_string();
tokens.expires_at = now_unix() + expires_in;
Ok(())
}
fn classify_refresh_failure(
status: reqwest::StatusCode,
body: &Value,
ctx: Option<AuthContext>,
) -> String {
let error_code = body.get("error").and_then(Value::as_str).unwrap_or("");
let description = body
.get("error_description")
.and_then(Value::as_str)
.unwrap_or("");
let scope_label = ctx.map_or("<scope>", AuthContext::label);
if error_code == "invalid_grant" {
return format!(
"google_auth: refresh token rejected (invalid_grant — usually \
revoked or expired). Recover with: \
`claudette --auth-google {scope_label} --revoke` then \
`claudette --auth-google {scope_label}`. \
Google said: {description}"
);
}
if status.is_server_error() {
return format!(
"google_auth: refresh HTTP {status} — Google's token endpoint \
returned a transient server error. Retry in a moment; if it \
keeps happening, check https://status.cloud.google.com/. \
Body: {}",
body.to_string().chars().take(200).collect::<String>()
);
}
let error_prefix = if error_code.is_empty() {
String::new()
} else {
format!("{error_code}: ")
};
format!(
"google_auth: refresh HTTP {status} — {error_prefix}{}",
body.to_string().chars().take(300).collect::<String>()
)
}
pub fn verify_scope_live(ctx: AuthContext, token: &str) -> Result<String, String> {
crate::egress::guard("https://www.googleapis.com")?;
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|e| format!("google_auth: http client: {e}"))?;
let url = match ctx {
AuthContext::Calendar => {
"https://www.googleapis.com/calendar/v3/calendars/primary/events?maxResults=1"
}
AuthContext::GmailRead => {
"https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults=1"
}
};
let resp = client
.get(url)
.bearer_auth(token)
.send()
.map_err(|e| format!("google_auth: verify request: {e}"))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().unwrap_or_default();
return Err(format!(
"google_auth: verify HTTP {} — {}",
status.as_u16(),
body.chars().take(160).collect::<String>()
));
}
let body: Value = resp
.json()
.map_err(|e| format!("google_auth: verify parse: {e}"))?;
match ctx {
AuthContext::Calendar => Ok("calendar access verified".to_string()),
AuthContext::GmailRead => {
let n = body
.get("resultSizeEstimate")
.and_then(Value::as_u64)
.unwrap_or(0);
Ok(format!("gmail access verified ({n} messages visible)"))
}
}
}
pub fn revoke(ctx: AuthContext) -> Result<(), String> {
crate::egress::guard(REVOKE_URL)?;
let tokens = load_tokens(ctx)?;
let client = http_client()?;
let resp = client
.post(REVOKE_URL)
.form(&[("token", tokens.refresh_token.as_str())])
.send()
.map_err(|e| format!("google_auth: revoke request failed: {e}"))?;
if !resp.status().is_success() {
eprintln!(
"google_auth: remote revoke returned HTTP {} — deleting local tokens anyway",
resp.status()
);
}
let path = tokens_path(ctx);
if path.exists() {
std::fs::remove_file(&path)
.map_err(|e| format!("google_auth: delete {}: {e}", path.display()))?;
}
Ok(())
}
pub fn run_auth_flow(ctx: AuthContext) -> Result<(), String> {
crate::egress::guard("https://accounts.google.com")?;
let creds = load_client_creds()?;
let listener =
TcpListener::bind("127.0.0.1:0").map_err(|e| format!("google_auth: bind loopback: {e}"))?;
let port = listener
.local_addr()
.map_err(|e| format!("google_auth: local_addr: {e}"))?
.port();
let redirect_uri = format!("http://127.0.0.1:{port}/callback");
let state = random_state();
let scopes = ctx.scopes().join(" ");
let authorize = format!(
"{AUTHORIZE_URL}?client_id={cid}&redirect_uri={redir}&response_type=code\
&scope={scope}&access_type=offline&prompt=consent&state={state}",
cid = url_encode(&creds.client_id),
redir = url_encode(&redirect_uri),
scope = url_encode(&scopes),
);
eprintln!(
"Opening browser to authorize Claudette with Google ({scope})…",
scope = ctx.label()
);
eprintln!("If it doesn't open, paste this URL manually:\n {authorize}\n");
let _ = open_browser(&authorize);
let (code, returned_state) = accept_callback(&listener)?;
if returned_state != state {
return Err(format!(
"google_auth: state mismatch (got '{returned_state}', expected '{state}') \
— possible CSRF; aborting"
));
}
let tokens = exchange_code(&creds, &code, &redirect_uri)?;
save_tokens(ctx, &tokens)?;
eprintln!(
"✔ Saved {} tokens to {}",
ctx.label(),
tokens_path(ctx).display()
);
match verify_scope_live(ctx, &tokens.access_token) {
Ok(msg) => eprintln!("✔ OK: {msg}"),
Err(e) => eprintln!(
"⚠ saved tokens but live verify failed: {e}. \
Re-run `claudette --doctor` after you fix it."
),
}
Ok(())
}
fn exchange_code(
creds: &ClientCreds,
code: &str,
redirect_uri: &str,
) -> Result<GoogleTokens, String> {
let client = http_client()?;
let params = [
("code", code),
("client_id", creds.client_id.as_str()),
("client_secret", creds.client_secret.as_str()),
("redirect_uri", redirect_uri),
("grant_type", "authorization_code"),
];
let resp = client
.post(TOKEN_URL)
.form(¶ms)
.send()
.map_err(|e| format!("google_auth: token exchange failed: {e}"))?;
let status = resp.status();
let body: Value = resp
.json()
.map_err(|e| format!("google_auth: token exchange parse: {e}"))?;
if !status.is_success() {
return Err(format!(
"google_auth: token HTTP {status}: {}",
body.to_string().chars().take(500).collect::<String>()
));
}
let access = body
.get("access_token")
.and_then(Value::as_str)
.ok_or("google_auth: response missing access_token")?;
let refresh = body
.get("refresh_token")
.and_then(Value::as_str)
.ok_or("google_auth: response missing refresh_token — did you include access_type=offline and prompt=consent?")?;
let expires_in = body
.get("expires_in")
.and_then(Value::as_i64)
.unwrap_or(3600);
let scope = body
.get("scope")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let token_type = body
.get("token_type")
.and_then(Value::as_str)
.unwrap_or("Bearer")
.to_string();
Ok(GoogleTokens {
access_token: access.to_string(),
refresh_token: refresh.to_string(),
expires_at: now_unix() + expires_in,
scope,
token_type,
})
}
fn accept_callback(listener: &TcpListener) -> Result<(String, String), String> {
listener
.set_nonblocking(false)
.map_err(|e| format!("google_auth: set_nonblocking: {e}"))?;
if let Some(stream) = listener.incoming().next() {
let mut stream = stream.map_err(|e| format!("google_auth: accept connection: {e}"))?;
let _ = stream.set_read_timeout(Some(Duration::from_secs(30)));
let _ = stream.set_write_timeout(Some(Duration::from_secs(5)));
let mut buf = [0u8; 4096];
let n = stream
.read(&mut buf)
.map_err(|e| format!("google_auth: read callback: {e}"))?;
let req = String::from_utf8_lossy(&buf[..n]);
let first = req.lines().next().unwrap_or("");
let mut parts = first.split_whitespace();
let _method = parts.next().unwrap_or("");
let target = parts.next().unwrap_or("");
let query = target.split_once('?').map_or("", |(_, q)| q);
let mut code = String::new();
let mut state = String::new();
let mut err = String::new();
for kv in query.split('&') {
let Some((k, v)) = kv.split_once('=') else {
continue;
};
let decoded = url_decode(v);
match k {
"code" => code = decoded,
"state" => state = decoded,
"error" => err = decoded,
_ => {}
}
}
let body_html = if err.is_empty() && !code.is_empty() {
"<html><body><h2>Claudette is authorized.</h2>\
<p>You can close this tab.</p></body></html>"
} else {
"<html><body><h2>Authorization failed.</h2>\
<p>Check the terminal for details.</p></body></html>"
};
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n\
Content-Length: {}\r\nConnection: close\r\n\r\n{}",
body_html.len(),
body_html
);
let _ = stream.write_all(response.as_bytes());
if !err.is_empty() {
return Err(format!("google_auth: Google returned error='{err}'"));
}
if code.is_empty() {
return Err(
"google_auth: callback missing 'code' param — did you cancel the consent screen?"
.to_string(),
);
}
return Ok((code, state));
}
Err("google_auth: listener closed before receiving callback".to_string())
}
fn open_browser(url: &str) -> std::io::Result<()> {
#[cfg(target_os = "windows")]
{
std::process::Command::new("rundll32")
.args(["url.dll,FileProtocolHandler", url])
.spawn()
.map(|_| ())
}
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.arg(url)
.spawn()
.map(|_| ())
}
#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
{
std::process::Command::new("xdg-open")
.arg(url)
.spawn()
.map(|_| ())
}
}
fn random_state() -> String {
let mut buf = [0u8; 16];
getrandom::fill(&mut buf).expect("OS RNG failed — refusing to use weaker entropy");
let mut out = String::with_capacity(32);
for b in buf {
use std::fmt::Write;
write!(out, "{b:02x}").expect("writing to String never fails");
}
out
}
fn url_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
_ => {
let _ = write!(out, "%{b:02X}");
}
}
}
out
}
fn url_decode(s: &str) -> String {
let mut out = Vec::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'+' => {
out.push(b' ');
i += 1;
}
b'%' if i + 2 < bytes.len() => {
let hi = hex_nibble(bytes[i + 1]);
let lo = hex_nibble(bytes[i + 2]);
if let (Some(h), Some(l)) = (hi, lo) {
out.push((h << 4) | l);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
b => {
out.push(b);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
fn hex_nibble(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn url_encode_leaves_unreserved() {
assert_eq!(url_encode("abcXYZ-_.~09"), "abcXYZ-_.~09");
}
#[test]
fn url_encode_percent_encodes_space_and_slash() {
assert_eq!(url_encode("hello world"), "hello%20world");
assert_eq!(url_encode("a/b"), "a%2Fb");
assert_eq!(url_encode("a:b"), "a%3Ab");
}
#[test]
fn url_decode_roundtrips_space_slash_colon() {
assert_eq!(url_decode("hello%20world"), "hello world");
assert_eq!(url_decode("a%2Fb"), "a/b");
assert_eq!(url_decode("a%3Ab"), "a:b");
}
#[test]
fn url_decode_handles_plus_as_space() {
assert_eq!(url_decode("hello+world"), "hello world");
}
#[test]
fn url_decode_keeps_unknown_escapes_literal() {
assert_eq!(url_decode("%ZZ"), "%ZZ");
}
#[test]
fn random_state_is_32_hex_chars() {
let s = random_state();
assert_eq!(s.len(), 32);
assert!(s.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn random_state_changes_between_calls() {
let a = random_state();
let b = random_state();
assert_ne!(a, b, "two state values should differ");
}
#[test]
fn random_state_has_spread_across_many_calls() {
let mut draws = std::collections::HashSet::new();
let mut first_bytes = std::collections::HashSet::new();
for _ in 0..100 {
let s = random_state();
assert_eq!(s.len(), 32);
first_bytes.insert(s[..2].to_string());
draws.insert(s);
}
assert_eq!(draws.len(), 100, "100 draws should all be distinct");
assert!(
first_bytes.len() >= 50,
"first-byte spread should be ≥50 unique, got {}",
first_bytes.len()
);
}
#[test]
fn tokens_path_under_secrets() {
let p = tokens_path(AuthContext::Calendar);
assert!(p.ends_with("google_oauth.json"));
assert!(p.parent().unwrap().ends_with("secrets"));
}
#[test]
fn gmail_tokens_path_differs_from_calendar() {
let cal = tokens_path(AuthContext::Calendar);
let gmail = tokens_path(AuthContext::GmailRead);
assert_ne!(cal, gmail);
assert!(gmail.ends_with("google_oauth_gmail_read.json"));
}
#[test]
fn client_path_under_secrets() {
let p = client_path();
assert!(p.ends_with("google_oauth_client.json"));
}
#[test]
fn auth_context_parse_accepts_canonical_and_aliases() {
assert_eq!(AuthContext::parse("calendar"), Some(AuthContext::Calendar));
assert_eq!(AuthContext::parse("cal"), Some(AuthContext::Calendar));
assert_eq!(AuthContext::parse("gcal"), Some(AuthContext::Calendar));
assert_eq!(AuthContext::parse("gmail"), Some(AuthContext::GmailRead));
assert_eq!(
AuthContext::parse("gmail-read"),
Some(AuthContext::GmailRead)
);
assert_eq!(AuthContext::parse("GMAIL"), Some(AuthContext::GmailRead));
assert_eq!(AuthContext::parse("unknown"), None);
assert_eq!(AuthContext::parse(""), None);
}
#[test]
fn auth_context_scopes_are_distinct() {
let cal = AuthContext::Calendar.scopes();
let gmail = AuthContext::GmailRead.scopes();
assert!(!cal.is_empty());
assert!(!gmail.is_empty());
assert_ne!(cal, gmail);
for s in gmail {
assert!(
!s.contains("gmail.send") && !s.contains("gmail.modify"),
"gmail-read scope leaked a write permission: {s}"
);
}
}
#[test]
fn load_client_creds_errors_when_missing() {
if std::env::var("CLAUDETTE_GOOGLE_CLIENT_ID").is_ok()
|| std::env::var("GOOGLE_CLIENT_ID").is_ok()
{
return;
}
if client_path().exists() {
return;
}
let err = load_client_creds().unwrap_err();
assert!(err.contains("OAuth client not configured"), "got: {err}");
assert!(err.contains("docs/google_setup.md"), "got: {err}");
}
#[test]
fn classify_refresh_failure_invalid_grant_names_revoke_then_reauth() {
let body = serde_json::json!({
"error": "invalid_grant",
"error_description": "Token has been expired or revoked."
});
let msg = classify_refresh_failure(
reqwest::StatusCode::BAD_REQUEST,
&body,
Some(AuthContext::Calendar),
);
assert!(msg.contains("invalid_grant"), "got: {msg}");
assert!(
msg.contains("--auth-google calendar --revoke"),
"got: {msg}"
);
assert!(msg.contains("--auth-google calendar"), "got: {msg}");
assert!(msg.contains("revoked or expired"), "got: {msg}");
}
#[test]
fn classify_refresh_failure_5xx_says_transient() {
let body = serde_json::json!({"error": "internal_error"});
let msg = classify_refresh_failure(
reqwest::StatusCode::INTERNAL_SERVER_ERROR,
&body,
Some(AuthContext::GmailRead),
);
assert!(msg.contains("transient"), "got: {msg}");
assert!(
!msg.contains("--revoke"),
"transient 5xx should not advise --revoke: {msg}"
);
}
#[test]
fn classify_refresh_failure_other_4xx_keeps_structured_error() {
let body = serde_json::json!({
"error": "unauthorized_client",
"error_description": "..."
});
let msg = classify_refresh_failure(
reqwest::StatusCode::UNAUTHORIZED,
&body,
Some(AuthContext::Calendar),
);
assert!(msg.contains("unauthorized_client"), "got: {msg}");
assert!(!msg.contains("--revoke"), "got: {msg}");
}
#[test]
fn classify_refresh_failure_handles_missing_context() {
let body = serde_json::json!({"error": "invalid_grant"});
let msg = classify_refresh_failure(reqwest::StatusCode::BAD_REQUEST, &body, None);
assert!(msg.contains("<scope>"), "got: {msg}");
}
#[test]
fn access_token_errors_without_tokens() {
if tokens_path(AuthContext::Calendar).exists() {
return;
}
let err = access_token(AuthContext::Calendar).unwrap_err();
assert!(err.contains("not authenticated"), "got: {err}");
assert!(err.contains("--auth-google"), "got: {err}");
}
}