Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 33 additions & 3 deletions src/credentials/keyring.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<u8>> {
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;
Expand All @@ -27,14 +27,44 @@ pub fn read(service: &str, account: &str) -> Option<Vec<u8>> {
}
}

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<u16> = 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<Vec<u8>> {
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<Vec<u8>> {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Security::Credentials::{
CRED_TYPE_GENERIC, CREDENTIALW, CredFree, CredReadW,
};

let target: Vec<u16> = std::ffi::OsStr::new(&format!("{service}:{account}"))
let target: Vec<u16> = std::ffi::OsStr::new(target_name)
.encode_wide()
.chain(std::iter::once(0))
.collect();
Expand Down
209 changes: 190 additions & 19 deletions src/credentials/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,19 +192,66 @@ pub fn load_codex() -> Option<Token> {
})
}

/// 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<Token> {
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::<Value>(&data)
&& let Some(token) = parse_claude(&value)
{
return Some(token);
}
}

None
}

pub(crate) fn parse_claude(root: &Value) -> Option<Token> {
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.
Expand All @@ -218,21 +265,88 @@ pub fn load_gemini() -> Option<Token> {
})
}

/// 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<Token> {
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::<Value>(&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<Token> {
// 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<Token> {
// 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
Expand Down Expand Up @@ -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 {
Expand Down
23 changes: 17 additions & 6 deletions src/parsers/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,18 @@ struct Payload {
#[derive(Debug, Deserialize)]
struct Bucket {
#[serde(default)]
utilization: f64,
utilization: Option<f64>,
#[serde(default)]
resets_at: String,
resets_at: Option<String>,
}

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)
}
Expand Down Expand Up @@ -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());
Expand Down
Loading