Skip to main content

agentmux_srv/identity/resolver/
oauth_probe.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! OAuth token-file probing: the [`oauth_status`] canonical-value
5//! constants, [`OAuthProbeStatus`], and [`probe_oauth_status`] itself.
6//!
7//! Split out of the single ~2193-line `resolver.rs` (pure relocation, no
8//! behavior change) — self-contained, on-disk OAuth token-file probing
9//! with no dependency on the rest of the resolver module.
10
11use std::path::Path;
12
13/// Canonical-value enumeration for OAuth-class `IdentityAccount.status`.
14///
15/// `IdentityAccount.status` is a `String` (free-form) at the SQLite layer
16/// — api-key rows keep using whatever the legacy paths wrote
17/// (`"unknown"`, `"ok"`, etc.). For oauth-class bindings we pin a small
18/// closed set per spec §4.4 so the frontend status-badge dispatch is
19/// deterministic and the resolver's expiry probe can never write an
20/// off-the-spec string. Every place the resolver SETS or READS an
21/// oauth-class status uses these constants.
22pub mod oauth_status {
23    /// Token file present and (probed) not expired.
24    pub const VALID: &str = "valid";
25    /// Access token expired; refresh likely succeeds.
26    pub const EXPIRED: &str = "expired";
27    /// Refresh rejected / file missing / parse error; user must Reconnect.
28    pub const NEEDS_REAUTH: &str = "needs_reauth";
29}
30
31/// Result of probing a per-bundle OAuth token directory.
32///
33/// Computed by [`probe_oauth_status`] reading the CLI's on-disk token
34/// file (e.g. `<dir>/.credentials.json` for Claude Code). Maps directly
35/// to [`oauth_status`] strings. Returned as an enum so the caller can
36/// branch without re-parsing the string.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum OAuthProbeStatus {
39    Valid,
40    Expired,
41    NeedsReauth,
42}
43
44impl OAuthProbeStatus {
45    pub fn as_str(self) -> &'static str {
46        match self {
47            Self::Valid => oauth_status::VALID,
48            Self::Expired => oauth_status::EXPIRED,
49            Self::NeedsReauth => oauth_status::NEEDS_REAUTH,
50        }
51    }
52}
53
54/// Cheap on-disk probe of the per-bundle OAuth token file for a
55/// provider. No network calls — just reads + parses the token JSON,
56/// then compares `expiresAt` against `now_ms`.
57///
58/// **Provider token-file shape (spec §4.4 + §4.5):**
59/// - `claude` — `<dir>/.credentials.json` with
60///   `{ "claudeAiOauth": { "accessToken", "refreshToken", "expiresAt": <ms> } }`
61///   (Anthropic's documented format — see
62///   `docs/specs/agentmux-isolated-auth.md` §1.6).
63/// - `codex` — `<dir>/.credentials.json` (MCP OAuth). Exact field
64///   layout undocumented by OpenAI; for now we treat presence-of-file
65///   as `Valid` and absence as `NeedsReauth`, deferring strict expiry
66///   parsing until the shape is pinned down. Falls through to the
67///   Claude parser as a best-effort — if the file is shape-compatible
68///   (some CLIs reuse Anthropic's format) the expiry check still works.
69/// - `openclaw` — same fallback as codex.
70///
71/// **Returns** `Some(status)` on a definitive read, `None` when probing
72/// isn't supported for the provider (so the caller skips status
73/// updates rather than mis-writing `needs_reauth` for a provider whose
74/// file we just don't know how to parse yet).
75///
76/// **macOS caveat (`claude` only):** the Claude Code CLI stores OAuth
77/// credentials in the encrypted macOS Keychain, never in
78/// `<dir>/.credentials.json` — confirmed against Claude Code's own docs
79/// and empirically on a real per-account auth dir on this machine (zero
80/// `.credentials.json` present despite a working session). Unlike Linux
81/// and Windows, this is true regardless of `CLAUDE_CONFIG_DIR` — see
82/// `docs/retro/retro-macos-keychain-credential-isolation-gap-2026-08-17.md`.
83/// So on macOS, a missing `claude` token file is not evidence the account
84/// needs reauth — it's the expected, permanent shape, and reporting
85/// `NeedsReauth` here would be a standing false positive on every working
86/// macOS Claude account. `None` is returned instead (status left alone) —
87/// see the `None` variant's doc above for why that's the honest answer
88/// when this probe genuinely can't tell. `codex`/`openclaw` are not
89/// covered by this carve-out: their macOS credential-storage behavior
90/// hasn't been verified, so their existing file-probe semantics are
91/// unchanged.
92pub fn probe_oauth_status(
93    provider: &str,
94    dir: &str,
95    now_ms: i64,
96) -> Option<OAuthProbeStatus> {
97    let probe_path: std::path::PathBuf = match provider {
98        // Claude Code + codex + openclaw all write to
99        // `<config_dir>/.credentials.json` per
100        // `docs/specs/provider-auth-isolation.md` (the agentmux-managed
101        // dir is what CLAUDE_CONFIG_DIR / CODEX_HOME / OPENCLAW_HOME
102        // point at). Codex / openclaw token field-layout is not
103        // publicly documented; the parser below treats unrecognised
104        // shapes as `Valid` so we don't false-positive a Reconnect on
105        // a working session — strict expiry parsing for those two is
106        // a follow-up once their JSON is pinned down.
107        "claude" | "codex" | "openclaw" => Path::new(dir).join(".credentials.json"),
108        _ => return None,
109    };
110
111    let contents = match std::fs::read_to_string(&probe_path) {
112        Ok(s) => s,
113        Err(e) => {
114            if provider == "claude" && cfg!(target_os = "macos") {
115                tracing::debug!(
116                    target: "identity",
117                    provider,
118                    path = %probe_path.display(),
119                    error = %e,
120                    "oauth probe: token file unreadable on macOS — Claude Code stores \
121                     credentials in Keychain here, not this file, so this is not evidence \
122                     of needs_reauth; leaving status unchanged"
123                );
124                return None;
125            }
126            tracing::debug!(
127                target: "identity",
128                provider,
129                path = %probe_path.display(),
130                error = %e,
131                "oauth probe: token file unreadable — status=needs_reauth"
132            );
133            return Some(OAuthProbeStatus::NeedsReauth);
134        }
135    };
136    let json: serde_json::Value = match serde_json::from_str(&contents) {
137        Ok(v) => v,
138        Err(e) => {
139            tracing::debug!(
140                target: "identity",
141                provider,
142                path = %probe_path.display(),
143                error = %e,
144                "oauth probe: token file parse failed — status=needs_reauth"
145            );
146            return Some(OAuthProbeStatus::NeedsReauth);
147        }
148    };
149
150    // Claude shape — `claudeAiOauth.expiresAt` is ms since epoch.
151    // Many shape-compatible providers nest under the same key; try
152    // that first, then fall back to any top-level `expiresAt` /
153    // `expires_at` an alternative provider might use.
154    let expires_at_ms = json
155        .get("claudeAiOauth")
156        .and_then(|o| o.get("expiresAt"))
157        .and_then(|v| v.as_i64())
158        .or_else(|| json.get("expiresAt").and_then(|v| v.as_i64()))
159        .or_else(|| json.get("expires_at").and_then(|v| v.as_i64()));
160
161    let has_refresh = json
162        .get("claudeAiOauth")
163        .and_then(|o| o.get("refreshToken"))
164        .and_then(|v| v.as_str())
165        .map(|s| !s.is_empty())
166        .unwrap_or(false)
167        || json
168            .get("refresh_token")
169            .and_then(|v| v.as_str())
170            .map(|s| !s.is_empty())
171            .unwrap_or(false);
172
173    match expires_at_ms {
174        Some(exp) if exp <= now_ms => {
175            // Past expiry. If a refresh token is present, the next
176            // CLI call will likely refresh it cleanly → `expired`
177            // (transient, not user-actionable). Without a refresh
178            // token the user must re-OAuth → `needs_reauth`.
179            if has_refresh {
180                Some(OAuthProbeStatus::Expired)
181            } else {
182                Some(OAuthProbeStatus::NeedsReauth)
183            }
184        }
185        Some(_) => Some(OAuthProbeStatus::Valid),
186        None => {
187            // Shape doesn't expose an expiry we can parse. Treat the
188            // file's existence as `Valid` rather than guess — false
189            // `needs_reauth` would force the user to reconnect a
190            // working session. codex / openclaw fall here today.
191            tracing::debug!(
192                target: "identity",
193                provider,
194                path = %probe_path.display(),
195                "oauth probe: file present but no parseable expiry — status=valid (best-effort)"
196            );
197            Some(OAuthProbeStatus::Valid)
198        }
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    // ── PR D — OAuth expiry probe + status semantics ───────────────────
207
208    /// Helper: write a Claude-shape `.credentials.json` into a temp dir
209    /// and return the dir path. `expires_ms` controls validity; `with_refresh`
210    /// toggles the refreshToken field so the resolver can distinguish
211    /// `Expired` (refresh present) from `NeedsReauth` (no refresh).
212    fn write_claude_creds(
213        dir: &std::path::Path,
214        expires_ms: i64,
215        with_refresh: bool,
216    ) {
217        std::fs::create_dir_all(dir).unwrap();
218        let body = serde_json::json!({
219            "claudeAiOauth": {
220                "accessToken": "test-access",
221                "refreshToken": if with_refresh { "test-refresh" } else { "" },
222                "expiresAt": expires_ms,
223            }
224        });
225        std::fs::write(
226            dir.join(".credentials.json"),
227            serde_json::to_string(&body).unwrap(),
228        )
229        .unwrap();
230    }
231
232    #[test]
233    fn probe_oauth_status_unknown_provider_returns_none() {
234        // Probing a provider that isn't in the oauth-class set is a
235        // signal to the caller to leave `status` alone — None ≠
236        // NeedsReauth. Guards against silent mis-classification of
237        // api-key providers if a future caller accidentally feeds
238        // them through here.
239        let r = probe_oauth_status("github", "/tmp/whatever", 0);
240        assert_eq!(r, None);
241    }
242
243    #[test]
244    fn probe_oauth_status_missing_dir_is_needs_reauth_for_codex() {
245        // codex isn't covered by the macOS carve-out (its macOS
246        // credential-storage behavior hasn't been verified the way
247        // Claude Code's has) — a missing file is still a definitive
248        // needs_reauth signal for it, on every platform.
249        let r = probe_oauth_status("codex", "/definitely/does/not/exist-xyz-9q", 0);
250        assert_eq!(r, Some(OAuthProbeStatus::NeedsReauth));
251    }
252
253    #[cfg(target_os = "macos")]
254    #[test]
255    fn probe_oauth_status_missing_dir_is_none_for_claude_on_macos() {
256        // Claude Code stores OAuth credentials in the macOS Keychain, not
257        // `<dir>/.credentials.json`, regardless of CLAUDE_CONFIG_DIR — a
258        // missing file here is the expected, permanent shape on macOS, not
259        // evidence of needs_reauth. See
260        // docs/retro/retro-macos-keychain-credential-isolation-gap-2026-08-17.md.
261        // Asserting `None` (not `NeedsReauth`) is what stops this probe
262        // from mislabeling every working macOS Claude account as needing
263        // reauth.
264        let r = probe_oauth_status("claude", "/definitely/does/not/exist-xyz-9q", 0);
265        assert_eq!(r, None);
266    }
267
268    #[cfg(not(target_os = "macos"))]
269    #[test]
270    fn probe_oauth_status_missing_dir_is_needs_reauth_for_claude_off_macos() {
271        // On Linux/Windows, CLAUDE_CONFIG_DIR genuinely relocates
272        // `.credentials.json` — a missing file there is a real signal.
273        let r = probe_oauth_status("claude", "/definitely/does/not/exist-xyz-9q", 0);
274        assert_eq!(r, Some(OAuthProbeStatus::NeedsReauth));
275    }
276
277    #[test]
278    fn probe_oauth_status_future_expiry_is_valid() {
279        let tmp = tempfile::tempdir().unwrap();
280        let now_ms = 1_700_000_000_000;
281        write_claude_creds(tmp.path(), now_ms + 3_600_000, true);
282        let r = probe_oauth_status("claude", tmp.path().to_str().unwrap(), now_ms);
283        assert_eq!(r, Some(OAuthProbeStatus::Valid));
284    }
285
286    #[test]
287    fn probe_oauth_status_past_expiry_with_refresh_is_expired() {
288        let tmp = tempfile::tempdir().unwrap();
289        let now_ms = 1_700_000_000_000;
290        write_claude_creds(tmp.path(), now_ms - 1, true);
291        let r = probe_oauth_status("claude", tmp.path().to_str().unwrap(), now_ms);
292        assert_eq!(r, Some(OAuthProbeStatus::Expired));
293    }
294
295    #[test]
296    fn probe_oauth_status_past_expiry_no_refresh_is_needs_reauth() {
297        // No refresh token in the file → the CLI can't auto-refresh
298        // and the user has to OAuth again. Maps to `needs_reauth`,
299        // NOT `expired` (per spec §4.4).
300        let tmp = tempfile::tempdir().unwrap();
301        let now_ms = 1_700_000_000_000;
302        write_claude_creds(tmp.path(), now_ms - 1, false);
303        let r = probe_oauth_status("claude", tmp.path().to_str().unwrap(), now_ms);
304        assert_eq!(r, Some(OAuthProbeStatus::NeedsReauth));
305    }
306
307    #[test]
308    fn probe_oauth_status_malformed_json_is_needs_reauth() {
309        let tmp = tempfile::tempdir().unwrap();
310        std::fs::write(tmp.path().join(".credentials.json"), "{ not json").unwrap();
311        let r = probe_oauth_status("claude", tmp.path().to_str().unwrap(), 0);
312        assert_eq!(r, Some(OAuthProbeStatus::NeedsReauth));
313    }
314
315    #[test]
316    fn probe_oauth_status_codex_unknown_shape_is_valid_best_effort() {
317        // codex / openclaw token-file layouts aren't publicly
318        // documented; our parser falls through to "Valid" when the
319        // file exists but lacks any parseable expiry. Better than
320        // false `needs_reauth` on a working session — strict parsing
321        // is a follow-up once the shape is pinned.
322        let tmp = tempfile::tempdir().unwrap();
323        std::fs::write(
324            tmp.path().join(".credentials.json"),
325            r#"{"some":"opaque-codex-blob"}"#,
326        )
327        .unwrap();
328        let r = probe_oauth_status("codex", tmp.path().to_str().unwrap(), 0);
329        assert_eq!(r, Some(OAuthProbeStatus::Valid));
330    }
331}