diff --git a/src/credentials/keyring.rs b/src/credentials/keyring.rs index 3c50d05..dc2a722 100644 --- a/src/credentials/keyring.rs +++ b/src/credentials/keyring.rs @@ -13,10 +13,10 @@ use std::process::Command; const GO_KEYRING_BASE64: &str = "go-keyring-base64:"; -/// Fetch a secret, decoding go-keyring's base64 wrapper when present. +/// Fetch a secret, decoding UTF-16LE and go-keyring's base64 wrapper when present. pub fn read(service: &str, account: &str) -> Option> { let raw = read_raw(service, account)?; - let text = String::from_utf8_lossy(&raw); + let text = decode_secret_bytes(&raw); let trimmed = text.trim(); if trimmed.is_empty() { return None; @@ -27,14 +27,44 @@ pub fn read(service: &str, account: &str) -> Option> { } } +fn decode_secret_bytes(raw: &[u8]) -> String { + // Windows keyring crate (windows-native-keyring-store) stores password strings as UTF-16LE. + if raw.len() >= 2 && raw.len().is_multiple_of(2) && raw[1] == 0 && raw[0] != 0 { + let (chunks, _) = raw.as_chunks::<2>(); + let units: Vec = chunks + .iter() + .map(|chunk| u16::from_le_bytes(*chunk)) + .collect(); + if let Ok(text) = String::from_utf16(&units) { + return text; + } + } + String::from_utf8_lossy(raw).to_string() +} + #[cfg(target_os = "windows")] fn read_raw(service: &str, account: &str) -> Option> { + for target_name in [ + format!("{service}:{account}"), + format!("{account}.{service}"), + format!("{service}/{account}"), + format!("{service}.{account}"), + ] { + if let Some(secret) = read_target(&target_name) { + return Some(secret); + } + } + None +} + +#[cfg(target_os = "windows")] +fn read_target(target_name: &str) -> Option> { use std::os::windows::ffi::OsStrExt; use windows_sys::Win32::Security::Credentials::{ CRED_TYPE_GENERIC, CREDENTIALW, CredFree, CredReadW, }; - let target: Vec = std::ffi::OsStr::new(&format!("{service}:{account}")) + let target: Vec = std::ffi::OsStr::new(target_name) .encode_wide() .chain(std::iter::once(0)) .collect(); diff --git a/src/credentials/mod.rs b/src/credentials/mod.rs index 3b08191..6caddc5 100644 --- a/src/credentials/mod.rs +++ b/src/credentials/mod.rs @@ -192,19 +192,66 @@ pub fn load_codex() -> Option { }) } -/// The OAuth session Claude Code keeps in `~/.claude/.credentials.json`. +/// The OAuth session Claude Code keeps in `~/.claude/.credentials.json`, +/// falling back to the OS keyring entry from cairn-code if absent. pub fn load_claude() -> Option { - let root = read_json(&home_dir().join(".claude").join(".credentials.json"))?; - let oauth = root.get("claudeAiOauth")?; + if let Some(root) = read_json(&home_dir().join(".claude").join(".credentials.json")) + && let Some(token) = parse_claude(&root) + { + return Some(token); + } - Some(Token { - access_token: string_at(oauth, "accessToken")?, - refresh_token: string_at(oauth, "refreshToken").unwrap_or_default(), - expires_at: expiry_at(oauth, &["expiresAt", "expires_at"]), - email: first_string(&[oauth, &root], &["email"]), - plan_type: first_string(&[oauth, &root], &["subscriptionType"]), - ..Default::default() - }) + for (service, account) in [ + ("cairn-code", "oauth:claude"), + ("cairn-code", "claude"), + ("claude", "credentials"), + ] { + if let Some(data) = keyring::read(service, account) + && let Ok(value) = serde_json::from_slice::(&data) + && let Some(token) = parse_claude(&value) + { + return Some(token); + } + } + + None +} + +pub(crate) fn parse_claude(root: &Value) -> Option { + if let Some(oauth) = root.get("claudeAiOauth") { + return Some(Token { + access_token: string_at(oauth, "accessToken")?, + refresh_token: string_at(oauth, "refreshToken").unwrap_or_default(), + expires_at: expiry_at(oauth, &["expiresAt", "expires_at"]), + email: first_string(&[oauth, root], &["email"]), + plan_type: first_string(&[oauth, root], &["subscriptionType"]), + ..Default::default() + }); + } + + // Direct OAuth token object (from keyring) + if let Some(access_token) = + string_at(root, "access_token").or_else(|| string_at(root, "accessToken")) + { + let refresh_token = string_at(root, "refresh_token") + .or_else(|| string_at(root, "refreshToken")) + .unwrap_or_default(); + let mut email = string_at(root, "email").unwrap_or_default(); + if email.is_empty() { + email = email_from_jwt(&access_token); + } + return Some(Token { + access_token, + refresh_token, + expires_at: expiry_at(root, &["expires_at", "expiresAt"]), + email, + plan_type: first_string(&[root], &["subscriptionType", "plan_type", "plan"]), + auth_method: "oauth".into(), + ..Default::default() + }); + } + + None } /// The Google OAuth credentials the Gemini CLI writes. @@ -218,21 +265,88 @@ pub fn load_gemini() -> Option { }) } -/// The Grok CLI's `~/.grok/auth.json`, which is a map of account entries. +/// The Grok / xAI token from the OS keyring (cairn-code device OAuth / API key) +/// or the Grok CLI's `~/.grok/auth.json`. pub fn load_grok() -> Option { + for (service, account) in [ + ("cairn-code", "oauth:xai"), + ("cairn-code", "xai"), + ("grok", "auth"), + ("grok", "oauth:grok"), + ] { + if let Some(data) = keyring::read(service, account) { + if let Ok(value) = serde_json::from_slice::(&data) { + if let Some(token) = parse_grok(&value) { + return Some(token); + } + } else { + let key = String::from_utf8_lossy(&data).trim().to_string(); + if !key.is_empty() && !key.starts_with('{') { + return Some(Token { + access_token: key, + auth_method: "api_key".into(), + ..Default::default() + }); + } + } + } + } + let root = read_json(&home_dir().join(".grok").join("auth.json"))?; - let entries = root.as_object()?; + parse_grok(&root) +} - entries.values().find_map(|entry| { - let access_token = string_at(entry, "key")?; - Some(Token { +pub(crate) fn parse_grok(root: &Value) -> Option { + // 1. If root is a map of account entries (as in ~/.grok/auth.json): + if let Some(entries) = root.as_object() + && let Some(token) = entries.values().find_map(parse_grok_entry) + { + return Some(token); + } + // 2. Direct token object (as in cairn-code keyring or single entry) + parse_grok_entry(root) +} + +fn parse_grok_entry(entry: &Value) -> Option { + // OAuth token format (cairn-code / device OAuth): "access_token" + if let Some(access_token) = + string_at(entry, "access_token").or_else(|| string_at(entry, "accessToken")) + { + let refresh_token = string_at(entry, "refresh_token") + .or_else(|| string_at(entry, "refreshToken")) + .unwrap_or_default(); + let mut email = string_at(entry, "email").unwrap_or_default(); + if email.is_empty() { + email = email_from_jwt(&access_token); + } + let id_token = string_at(entry, "id_token").unwrap_or_default(); + if email.is_empty() && !id_token.is_empty() { + email = email_from_jwt(&id_token); + } + return Some(Token { + access_token, + refresh_token, + email, + expires_at: expiry_at(entry, &["expires_at", "expiresAt", "expiry"]), + auth_method: string_at(entry, "auth_mode") + .or_else(|| string_at(entry, "token_type")) + .unwrap_or_else(|| "oauth".into()), + ..Default::default() + }); + } + + // CLI format (~/.grok/auth.json): "key" + if let Some(access_token) = string_at(entry, "key") { + return Some(Token { access_token, email: string_at(entry, "email").unwrap_or_default(), - expires_at: expiry_at(entry, &["expires_at"]), + expires_at: expiry_at(entry, &["expires_at", "expiresAt"]), auth_method: string_at(entry, "auth_mode").unwrap_or_else(|| "oidc".into()), ..Default::default() - }) - }) + }); + } + + None } /// Antigravity's OAuth token, from the keyring first and the on-disk stores @@ -541,6 +655,63 @@ mod tests { } } + #[test] + fn parse_grok_oauth_payload() { + let payload = json!({ + "access_token": "xai-oauth-access-token", + "refresh_token": "xai-refresh-token", + "token_type": "Bearer", + "expires_at": 1786741307, + "scopes": ["openid", "profile", "email"] + }); + let token = parse_grok(&payload).unwrap(); + assert_eq!(token.access_token, "xai-oauth-access-token"); + assert_eq!(token.refresh_token, "xai-refresh-token"); + assert_eq!(token.expires_at, Some(1786741307)); + } + + #[test] + fn parse_grok_cli_auth_json_map() { + let payload = json!({ + "account1": { + "key": "xai-api-key-test", + "email": "grokuser@example.com", + "expires_at": 1786741307, + "auth_mode": "oidc" + } + }); + let token = parse_grok(&payload).unwrap(); + assert_eq!(token.access_token, "xai-api-key-test"); + assert_eq!(token.email, "grokuser@example.com"); + assert_eq!(token.auth_method, "oidc"); + } + + #[test] + fn parse_claude_oauth_direct_and_nested() { + let nested = json!({ + "claudeAiOauth": { + "accessToken": "claude-nested-token", + "refreshToken": "claude-refresh", + "expiresAt": "2026-08-14T21:01:47Z", + "email": "user@claude.ai", + "subscriptionType": "pro" + } + }); + let tok1 = parse_claude(&nested).unwrap(); + assert_eq!(tok1.access_token, "claude-nested-token"); + assert_eq!(tok1.email, "user@claude.ai"); + + let direct = json!({ + "access_token": "claude-direct-token", + "refresh_token": "claude-direct-refresh", + "expires_at": 1786741307, + "subscriptionType": "max" + }); + let tok2 = parse_claude(&direct).unwrap(); + assert_eq!(tok2.access_token, "claude-direct-token"); + assert_eq!(tok2.plan_type, "max"); + } + #[test] fn a_token_without_an_expiry_is_treated_as_fresh() { let token = Token { diff --git a/src/parsers/claude.rs b/src/parsers/claude.rs index 97ac943..36fb89e 100644 --- a/src/parsers/claude.rs +++ b/src/parsers/claude.rs @@ -14,18 +14,18 @@ struct Payload { #[derive(Debug, Deserialize)] struct Bucket { #[serde(default)] - utilization: f64, + utilization: Option, #[serde(default)] - resets_at: String, + resets_at: Option, } impl Bucket { fn into_window(self, label: &str, seconds: i64) -> UsageWindow { - let countdown = match self.resets_at.trim() { - "" => "Unknown".to_string(), - at => format_countdown(at), + let countdown = match self.resets_at.as_deref().map(str::trim) { + None | Some("") => "Unknown".to_string(), + Some(at) => format_countdown(at), }; - UsageWindow::new(label, self.utilization) + UsageWindow::new(label, self.utilization.unwrap_or(0.0)) .reset(countdown) .seconds(seconds) } @@ -103,6 +103,17 @@ mod tests { assert_eq!(windows[0].reset_countdown, "Unknown"); } + #[test] + fn null_fields_in_bucket_survive_deserialization() { + let windows = parse(r#"{"five_hour":{"utilization":0.0,"resets_at":null},"seven_day":{"utilization":99.0,"resets_at":"2026-08-18T10:59:59.644698+00:00"}}"#); + assert_eq!(windows.len(), 2); + assert_eq!(windows[0].label, "Session"); + assert_eq!(windows[0].used_percent, 0.0); + assert_eq!(windows[0].reset_countdown, "Unknown"); + assert_eq!(windows[1].label, "Weekly"); + assert_eq!(windows[1].used_percent, 99.0); + } + #[test] fn an_unusable_payload_yields_no_windows() { assert!(parse("{}").is_empty());