Skip to main content

agentmux_srv/identity/
auth_diag.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Credential-state diagnostics for login/logout robustness runs.
5//!
6//! The auth flow has a long history of bugs that only show up across
7//! *repeated* login/logout rounds — a login writing tokens to one dir while
8//! the check/spawn reads another, a logout that reports "removed" but leaves
9//! the file behind, a stale re-seed sentinel that blocks a fresh import, or a
10//! present-but-stale token surviving a logout. None of those are visible from
11//! a single "authenticated: true/false" line.
12//!
13//! [`snapshot`] captures the observable on-disk truth for a Claude config dir
14//! as one compact, greppable string. It includes a **fingerprint** of the
15//! access token — a non-reversible 32-bit hash, never the token itself — so
16//! you can tell "same token as last round" from "new token" while diffing a
17//! `muxlog auth` trace, without any secret touching the log.
18//!
19//! Emit it at the login-check boundary and around logout/removal; the
20//! `auth.credstate:` / `identity.delete:` message prefixes are already
21//! `muxlog auth` vocabulary.
22
23use std::path::Path;
24
25/// Non-reversible fingerprint of a secret, for round-to-round diffing only.
26///
27/// FNV-1a over the bytes, low 32 bits as 8 hex chars. Deterministic across
28/// process restarts (unlike `DefaultHasher`), so fingerprints stay comparable
29/// even if the srv bounces mid-run. 32 bits of a hash of a ~100-char
30/// high-entropy token is not a meaningful disclosure — but keep it a
31/// fingerprint, never widen it toward the raw value.
32fn fingerprint(secret: &str) -> String {
33    const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
34    const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
35    let mut h = FNV_OFFSET;
36    for b in secret.as_bytes() {
37        h ^= *b as u64;
38        h = h.wrapping_mul(FNV_PRIME);
39    }
40    format!("{:08x}", (h & 0xffff_ffff) as u32)
41}
42
43/// A one-line, secret-free snapshot of a Claude credential dir. Fields:
44///   `present`  — `.credentials.json` exists
45///   `token`    — access-token fingerprint (`none` if absent/empty)
46///   `refresh`  — a non-empty refresh token is present
47///   `seeded`   — the one-time global-import sentinel exists
48///   `mtime`    — credentials.json mtime as unix secs (`0` if unknown)
49///
50/// `config_dir` is the value of `CLAUDE_CONFIG_DIR` (the isolated/shared
51/// provider dir); pass the resolved dir the caller actually reads/writes so
52/// the snapshot reflects the SAME location, not a guessed one.
53pub fn snapshot(config_dir: &str) -> String {
54    let dir = Path::new(config_dir);
55    let creds = dir.join(".credentials.json");
56    let seeded = dir.join(".agentmux-cred-seeded").exists();
57
58    let (present, token_fp, refresh) = match std::fs::read_to_string(&creds) {
59        Ok(content) => {
60            let (mut fp, mut has_refresh) = ("none".to_string(), false);
61            if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
62                let oauth = json.get("claudeAiOauth");
63                if let Some(tok) = oauth
64                    .and_then(|o| o.get("accessToken"))
65                    .and_then(|v| v.as_str())
66                    .filter(|s| !s.is_empty())
67                {
68                    fp = fingerprint(tok);
69                }
70                has_refresh = oauth
71                    .and_then(|o| o.get("refreshToken"))
72                    .and_then(|v| v.as_str())
73                    .map(|s| !s.is_empty())
74                    .unwrap_or(false);
75            }
76            (true, fp, has_refresh)
77        }
78        Err(_) => (false, "none".to_string(), false),
79    };
80
81    let mtime = std::fs::metadata(&creds)
82        .and_then(|m| m.modified())
83        .ok()
84        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
85        .map(|d| d.as_secs())
86        .unwrap_or(0);
87
88    format!(
89        "dir={config_dir} present={present} token={token_fp} refresh={refresh} seeded={seeded} mtime={mtime}"
90    )
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn fingerprint_is_stable_and_distinct() {
99        // Deterministic (no process-random seed) and secret-free.
100        assert_eq!(fingerprint("sk-ant-oat-AAAA"), fingerprint("sk-ant-oat-AAAA"));
101        assert_ne!(fingerprint("sk-ant-oat-AAAA"), fingerprint("sk-ant-oat-BBBB"));
102        let fp = fingerprint("sk-ant-oat-secretsecret");
103        assert_eq!(fp.len(), 8);
104        assert!(!fp.contains("secret"));
105    }
106
107    #[test]
108    fn snapshot_absent_dir() {
109        let snap = snapshot("/nonexistent/agentmux-test-dir-xyz");
110        assert!(snap.contains("present=false"));
111        assert!(snap.contains("token=none"));
112        assert!(snap.contains("refresh=false"));
113        assert!(snap.contains("seeded=false"));
114    }
115
116    #[test]
117    fn snapshot_reports_token_fingerprint_and_refresh() {
118        let dir = std::env::temp_dir().join(format!("amx-auth-diag-{}", std::process::id()));
119        std::fs::create_dir_all(&dir).unwrap();
120        let creds = dir.join(".credentials.json");
121        std::fs::write(
122            &creds,
123            r#"{"claudeAiOauth":{"accessToken":"sk-ant-oat-TESTTOKEN","refreshToken":"rt-xyz"}}"#,
124        )
125        .unwrap();
126
127        let snap = snapshot(dir.to_str().unwrap());
128        assert!(snap.contains("present=true"), "{snap}");
129        assert!(snap.contains("refresh=true"), "{snap}");
130        assert!(!snap.contains("TESTTOKEN"), "token must never appear: {snap}");
131        assert!(
132            snap.contains(&format!("token={}", fingerprint("sk-ant-oat-TESTTOKEN"))),
133            "{snap}"
134        );
135
136        let _ = std::fs::remove_dir_all(&dir);
137    }
138}