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}