Skip to main content

claude_wrapper/
auth.rs

1//! Detect which auth strategy the embedded Claude Code CLI will use.
2//!
3//! Claude Code resolves auth at invocation time by inspecting a few
4//! environment variables, falling back to credentials stored under
5//! `~/.claude/` when none are set. This module mirrors that
6//! precedence as a cheap, sync, env-only check so hosts can introspect
7//! the active mode before spawning a turn.
8//!
9//! It is **not** a liveness check -- a reported [`AuthStrategy::Subscription`]
10//! only means "no env auth set"; the user might not have run
11//! `claude login` yet. Use the `claude auth status` CLI for that.
12//!
13//! # Precedence
14//!
15//! 1. `CLAUDE_CODE_USE_BEDROCK` truthy -> [`AuthStrategy::Bedrock`]
16//! 2. `CLAUDE_CODE_USE_VERTEX` truthy -> [`AuthStrategy::Vertex`]
17//! 3. `ANTHROPIC_API_KEY` non-empty -> [`AuthStrategy::ApiKey`]
18//! 4. `CLAUDE_CODE_OAUTH_TOKEN` non-empty -> [`AuthStrategy::OauthToken`]
19//! 5. Otherwise -> [`AuthStrategy::Subscription`]
20//!
21//! Cloud-provider strategies (Bedrock, Vertex) take precedence because
22//! they redirect ALL traffic regardless of API key presence.
23//!
24//! # Example
25//!
26//! ```
27//! use claude_wrapper::auth;
28//!
29//! let summary = auth::detect();
30//! println!("strategy: {:?}", summary.strategy);
31//! if summary.has_anthropic_api_key {
32//!     println!("note: ANTHROPIC_API_KEY is set in the environment");
33//! }
34//! ```
35
36use std::collections::HashMap;
37
38use serde::Serialize;
39
40/// Active auth strategy, as inferred from the host environment.
41///
42/// See module-level docs for precedence rules.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
44#[serde(rename_all = "snake_case")]
45pub enum AuthStrategy {
46    /// `CLAUDE_CODE_USE_BEDROCK` is truthy. Requests are routed to
47    /// AWS Bedrock; AWS credentials are resolved separately by the
48    /// Bedrock SDK from the host environment.
49    Bedrock,
50    /// `CLAUDE_CODE_USE_VERTEX` is truthy. Requests are routed to
51    /// Google Vertex; GCP credentials are resolved separately.
52    Vertex,
53    /// `ANTHROPIC_API_KEY` is set. Direct API access, billed to that key.
54    ApiKey,
55    /// `CLAUDE_CODE_OAUTH_TOKEN` is set. OAuth token (typically from
56    /// `claude setup-token`).
57    OauthToken,
58    /// No auth env var set. The CLI will look for stored credentials
59    /// under `~/.claude/` (the result of an interactive `claude login`).
60    /// May or may not actually be authenticated -- this strategy
61    /// reports "the env doesn't pin anything," not "you are logged in."
62    Subscription,
63}
64
65impl AuthStrategy {
66    /// Stable string label, useful for logs and protocol payloads.
67    /// Matches the `serde_json` representation.
68    pub fn as_str(self) -> &'static str {
69        match self {
70            Self::Bedrock => "bedrock",
71            Self::Vertex => "vertex",
72            Self::ApiKey => "api_key",
73            Self::OauthToken => "oauth_token",
74            Self::Subscription => "subscription",
75        }
76    }
77}
78
79/// Snapshot of auth-relevant environment state. Returned by [`detect`]
80/// so callers see both the resolved strategy and the raw signals that
81/// drove the decision.
82#[derive(Debug, Clone, Serialize)]
83pub struct AuthSummary {
84    /// The strategy the CLI will pick under the current env.
85    pub strategy: AuthStrategy,
86    /// Whether `ANTHROPIC_API_KEY` is set and non-empty.
87    pub has_anthropic_api_key: bool,
88    /// Whether `CLAUDE_CODE_OAUTH_TOKEN` is set and non-empty.
89    pub has_oauth_token: bool,
90    /// Whether `CLAUDE_CODE_USE_BEDROCK` is truthy (`1`, `true`, etc.).
91    pub bedrock_enabled: bool,
92    /// Whether `CLAUDE_CODE_USE_VERTEX` is truthy.
93    pub vertex_enabled: bool,
94}
95
96/// Best-effort classification of an auth-related CLI failure.
97///
98/// Returned by [`classify_failure`]. Hosts can use it to surface a
99/// cleaner message ("run `claude login`") instead of dumping CLI
100/// stderr, or to skip retry policies on errors that won't resolve
101/// on their own.
102///
103/// Conservative on purpose: false positives turn legitimate non-auth
104/// failures into "auth error" surprises, so the classifier prefers
105/// to miss an auth error than to misclassify a non-auth one. Use
106/// [`AuthErrorKind::Other`] only when stronger signals (HTTP status
107/// strings, the literal word "auth") fire.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
109#[serde(rename_all = "snake_case")]
110pub enum AuthErrorKind {
111    /// No credentials at all -- the user has not run `claude login`
112    /// and has no env auth set. Fix: run `claude login` or set one
113    /// of the env vars listed in [`AuthStrategy`].
114    NotAuthenticated,
115    /// Stored OAuth/session credentials existed but are expired.
116    /// Fix: re-run `claude login`.
117    Expired,
118    /// Credentials were presented but rejected. Most often a wrong
119    /// or revoked `ANTHROPIC_API_KEY`, or an `CLAUDE_CODE_OAUTH_TOKEN`
120    /// that no longer maps to a valid session.
121    InvalidCredentials,
122    /// Authenticated but the request was rejected for rate limit /
123    /// quota / billing reasons. Different remediation: wait, top up,
124    /// or switch keys -- not "log in again."
125    RateLimit,
126    /// Bedrock or Vertex provider error (cloud creds missing or
127    /// rejected). Distinct because the fix lives in the cloud
128    /// provider's auth, not in `claude login`.
129    ProviderError,
130    /// Looked auth-shaped (HTTP 401/403, the word "auth", etc.) but
131    /// didn't match any of the more specific patterns. Useful for
132    /// callers that want "is this an auth thing?" without needing
133    /// to know the exact subcategory.
134    Other,
135}
136
137impl AuthErrorKind {
138    /// Stable string label, useful for logs and protocol payloads.
139    /// Matches the `serde_json` representation.
140    pub fn as_str(self) -> &'static str {
141        match self {
142            Self::NotAuthenticated => "not_authenticated",
143            Self::Expired => "expired",
144            Self::InvalidCredentials => "invalid_credentials",
145            Self::RateLimit => "rate_limit",
146            Self::ProviderError => "provider_error",
147            Self::Other => "other",
148        }
149    }
150}
151
152/// Inspect a failed `claude` invocation and decide whether it looks
153/// auth-shaped. Returns `Some(kind)` only when the patterns are
154/// confident enough to risk relabeling.
155///
156/// `exit_code`, `stdout`, and `stderr` come from the CLI's exit; the
157/// classifier matches against the lowercased concatenation. The
158/// patterns are intentionally narrow:
159///
160/// - "may not exist" / "may not have access" / "not_found_error" /
161///   "issue with the selected model" -> NOT auth (`None`): a
162///   model-not-found / model-access failure is a bad `--model`, not a
163///   credential problem, even when it carries a 403/404 status.
164/// - "not authenticated" / "not logged in" / "claude login" /
165///   "please run /login" / "run /login" / "no credentials" / "no auth"
166///   -> [`AuthErrorKind::NotAuthenticated`]
167/// - "expired" / "session has expired" / "token expired" -> [`AuthErrorKind::Expired`]
168/// - "invalid api key" / "invalid token" / "401" / "unauthorized" / "403"
169///   / "forbidden" -> [`AuthErrorKind::InvalidCredentials`]
170/// - "rate limit" / "quota" / "too many requests" / "429" -> [`AuthErrorKind::RateLimit`]
171/// - "bedrock" or "vertex" present alongside an auth signal -> [`AuthErrorKind::ProviderError`]
172/// - bare "auth" / "credential" hit with nothing more specific -> [`AuthErrorKind::Other`]
173pub fn classify_failure(_exit_code: i32, stdout: &str, stderr: &str) -> Option<AuthErrorKind> {
174    let combined = format!("{stdout}\n{stderr}").to_ascii_lowercase();
175
176    // Model-not-found / model-access failures are NOT auth errors,
177    // even though they can carry a 403/404 HTTP status that the
178    // checks below would otherwise read as `InvalidCredentials`. A
179    // bad `--model` id is a typo, not a credential problem; relabeling
180    // it as auth makes downstream consumers (e.g. roba, which maps
181    // `Error::Auth` to a fleet-halting exit code) treat a model typo
182    // like a credential failure. Bail to `None` (generic
183    // `CommandFailed`) before any auth pattern can fire.
184    let mentions_model_problem = combined.contains("may not exist")
185        || combined.contains("may not have access")
186        || combined.contains("not_found_error")
187        || combined.contains("issue with the selected model");
188    if mentions_model_problem {
189        return None;
190    }
191
192    // Provider hits (Bedrock / Vertex) take precedence when the
193    // failure mentions them alongside an auth signal -- the fix is
194    // different (cloud creds, not `claude login`).
195    let mentions_provider = combined.contains("bedrock") || combined.contains("vertex");
196    let mentions_auth_signal = combined.contains("auth")
197        || combined.contains("credential")
198        || combined.contains("401")
199        || combined.contains("403")
200        || combined.contains("forbidden")
201        || combined.contains("unauthorized");
202    if mentions_provider && mentions_auth_signal {
203        return Some(AuthErrorKind::ProviderError);
204    }
205
206    if combined.contains("rate limit")
207        || combined.contains("too many requests")
208        || combined.contains("429")
209        || combined.contains("quota")
210    {
211        return Some(AuthErrorKind::RateLimit);
212    }
213
214    if combined.contains("expired")
215        || combined.contains("session has expired")
216        || combined.contains("token expired")
217    {
218        return Some(AuthErrorKind::Expired);
219    }
220
221    if combined.contains("invalid api key")
222        || combined.contains("invalid token")
223        || combined.contains("401")
224        || combined.contains("unauthorized")
225        || combined.contains("403")
226        || combined.contains("forbidden")
227    {
228        return Some(AuthErrorKind::InvalidCredentials);
229    }
230
231    if combined.contains("not authenticated")
232        || combined.contains("not logged in")
233        || combined.contains("claude login")
234        || combined.contains("please run /login")
235        || combined.contains("run /login")
236        || combined.contains("no credentials")
237        || combined.contains("no auth")
238    {
239        return Some(AuthErrorKind::NotAuthenticated);
240    }
241
242    // Last-resort bucket: a bare "auth" or "credential" mention
243    // without specifics. Conservative: only fires when the word is
244    // present in stderr (where these errors typically land) so we
245    // don't catch `--allowed-tools auth_helper` or similar.
246    if stderr.to_ascii_lowercase().contains("auth")
247        || stderr.to_ascii_lowercase().contains("credential")
248    {
249        return Some(AuthErrorKind::Other);
250    }
251
252    None
253}
254
255/// Detect the active auth strategy from the current process
256/// environment. Cheap; no subprocess, no filesystem reads.
257pub fn detect() -> AuthSummary {
258    let env: HashMap<String, String> = std::env::vars().collect();
259    detect_from(&env)
260}
261
262/// Same as [`detect`] but reads from a caller-provided env map.
263/// Exposed for tests and for hosts that want to introspect a child
264/// environment they're about to spawn under.
265pub fn detect_from(env: &HashMap<String, String>) -> AuthSummary {
266    let bedrock_enabled = is_truthy(env.get("CLAUDE_CODE_USE_BEDROCK").map(String::as_str));
267    let vertex_enabled = is_truthy(env.get("CLAUDE_CODE_USE_VERTEX").map(String::as_str));
268    let has_anthropic_api_key = is_set(env.get("ANTHROPIC_API_KEY").map(String::as_str));
269    let has_oauth_token = is_set(env.get("CLAUDE_CODE_OAUTH_TOKEN").map(String::as_str));
270
271    let strategy = if bedrock_enabled {
272        AuthStrategy::Bedrock
273    } else if vertex_enabled {
274        AuthStrategy::Vertex
275    } else if has_anthropic_api_key {
276        AuthStrategy::ApiKey
277    } else if has_oauth_token {
278        AuthStrategy::OauthToken
279    } else {
280        AuthStrategy::Subscription
281    };
282
283    AuthSummary {
284        strategy,
285        has_anthropic_api_key,
286        has_oauth_token,
287        bedrock_enabled,
288        vertex_enabled,
289    }
290}
291
292/// Treat any non-empty, non-whitespace value as "set."
293fn is_set(value: Option<&str>) -> bool {
294    value.is_some_and(|v| !v.trim().is_empty())
295}
296
297/// Truthy env var: any non-empty value that isn't a recognized falsy
298/// literal (`0`, `false`, `no`, case-insensitive). Mirrors the loose
299/// convention most CLI tools follow for `XYZ_USE_FOO` toggles.
300fn is_truthy(value: Option<&str>) -> bool {
301    let Some(v) = value else { return false };
302    let trimmed = v.trim();
303    if trimmed.is_empty() {
304        return false;
305    }
306    !matches!(
307        trimmed.to_ascii_lowercase().as_str(),
308        "0" | "false" | "no" | "off"
309    )
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    fn env(pairs: &[(&str, &str)]) -> HashMap<String, String> {
317        pairs
318            .iter()
319            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
320            .collect()
321    }
322
323    #[test]
324    fn empty_env_is_subscription() {
325        let s = detect_from(&env(&[]));
326        assert_eq!(s.strategy, AuthStrategy::Subscription);
327        assert!(!s.has_anthropic_api_key);
328        assert!(!s.has_oauth_token);
329        assert!(!s.bedrock_enabled);
330        assert!(!s.vertex_enabled);
331    }
332
333    #[test]
334    fn api_key_takes_precedence_over_oauth_token() {
335        let s = detect_from(&env(&[
336            ("ANTHROPIC_API_KEY", "sk-abc"),
337            ("CLAUDE_CODE_OAUTH_TOKEN", "tok-xyz"),
338        ]));
339        assert_eq!(s.strategy, AuthStrategy::ApiKey);
340        assert!(s.has_anthropic_api_key);
341        assert!(s.has_oauth_token);
342    }
343
344    #[test]
345    fn oauth_token_alone_picks_oauth() {
346        let s = detect_from(&env(&[("CLAUDE_CODE_OAUTH_TOKEN", "tok-xyz")]));
347        assert_eq!(s.strategy, AuthStrategy::OauthToken);
348        assert!(!s.has_anthropic_api_key);
349        assert!(s.has_oauth_token);
350    }
351
352    #[test]
353    fn bedrock_overrides_api_key() {
354        let s = detect_from(&env(&[
355            ("CLAUDE_CODE_USE_BEDROCK", "1"),
356            ("ANTHROPIC_API_KEY", "sk-abc"),
357        ]));
358        assert_eq!(s.strategy, AuthStrategy::Bedrock);
359        assert!(s.bedrock_enabled);
360        assert!(s.has_anthropic_api_key);
361    }
362
363    #[test]
364    fn vertex_overrides_oauth_token() {
365        let s = detect_from(&env(&[
366            ("CLAUDE_CODE_USE_VERTEX", "true"),
367            ("CLAUDE_CODE_OAUTH_TOKEN", "tok-xyz"),
368        ]));
369        assert_eq!(s.strategy, AuthStrategy::Vertex);
370        assert!(s.vertex_enabled);
371    }
372
373    #[test]
374    fn bedrock_takes_precedence_over_vertex_when_both_set() {
375        let s = detect_from(&env(&[
376            ("CLAUDE_CODE_USE_BEDROCK", "1"),
377            ("CLAUDE_CODE_USE_VERTEX", "1"),
378        ]));
379        assert_eq!(s.strategy, AuthStrategy::Bedrock);
380        assert!(s.bedrock_enabled);
381        assert!(s.vertex_enabled);
382    }
383
384    #[test]
385    fn empty_string_does_not_count_as_set() {
386        let s = detect_from(&env(&[
387            ("ANTHROPIC_API_KEY", ""),
388            ("CLAUDE_CODE_OAUTH_TOKEN", "   "),
389        ]));
390        assert_eq!(s.strategy, AuthStrategy::Subscription);
391    }
392
393    #[test]
394    fn explicit_falsy_disables_provider_flag() {
395        let s = detect_from(&env(&[
396            ("CLAUDE_CODE_USE_BEDROCK", "0"),
397            ("CLAUDE_CODE_USE_VERTEX", "false"),
398            ("ANTHROPIC_API_KEY", "sk-abc"),
399        ]));
400        assert_eq!(s.strategy, AuthStrategy::ApiKey);
401        assert!(!s.bedrock_enabled);
402        assert!(!s.vertex_enabled);
403    }
404
405    #[test]
406    fn truthy_values_recognized() {
407        for v in ["1", "true", "TRUE", "yes", "on", "anything"] {
408            let s = detect_from(&env(&[("CLAUDE_CODE_USE_BEDROCK", v)]));
409            assert_eq!(s.strategy, AuthStrategy::Bedrock, "value {v:?}");
410        }
411    }
412
413    #[test]
414    fn falsy_values_recognized() {
415        for v in ["0", "false", "FALSE", "no", "off"] {
416            let s = detect_from(&env(&[("CLAUDE_CODE_USE_BEDROCK", v)]));
417            assert_eq!(s.strategy, AuthStrategy::Subscription, "value {v:?}");
418            assert!(!s.bedrock_enabled, "value {v:?}");
419        }
420    }
421
422    // -- classify_failure ---------------------------------------------
423
424    #[test]
425    fn classify_returns_none_for_unrelated_failure() {
426        assert_eq!(classify_failure(1, "no match found", ""), None);
427        assert_eq!(
428            classify_failure(2, "", "syntax error near unexpected token"),
429            None
430        );
431    }
432
433    #[test]
434    fn classify_not_authenticated_from_stderr_hint() {
435        assert_eq!(
436            classify_failure(1, "", "Not authenticated. Run `claude login` to sign in."),
437            Some(AuthErrorKind::NotAuthenticated)
438        );
439        assert_eq!(
440            classify_failure(1, "", "no credentials configured"),
441            Some(AuthErrorKind::NotAuthenticated)
442        );
443    }
444
445    #[test]
446    fn classify_expired_session() {
447        assert_eq!(
448            classify_failure(1, "", "Your session has expired. Please log in again."),
449            Some(AuthErrorKind::Expired)
450        );
451        assert_eq!(
452            classify_failure(1, "", "token expired at 2025-01-01T00:00:00Z"),
453            Some(AuthErrorKind::Expired)
454        );
455    }
456
457    #[test]
458    fn classify_invalid_api_key() {
459        assert_eq!(
460            classify_failure(1, "", "Invalid API key. Check ANTHROPIC_API_KEY."),
461            Some(AuthErrorKind::InvalidCredentials)
462        );
463        assert_eq!(
464            classify_failure(1, "", "HTTP 401 Unauthorized"),
465            Some(AuthErrorKind::InvalidCredentials)
466        );
467        assert_eq!(
468            classify_failure(1, "", "403 Forbidden"),
469            Some(AuthErrorKind::InvalidCredentials)
470        );
471    }
472
473    #[test]
474    fn classify_model_not_found_is_not_auth() {
475        // A bad `--model` id comes back with a 404/403 payload but is a
476        // typo, not a credential problem. It must not be relabeled as
477        // auth (regression for #632: a model typo halted roba fleets).
478        // Mirrors real claude `--output-format json` output.
479        let bad_model = r#"{"type":"result","is_error":true,"api_error_status":404,"result":"There's an issue with the selected model (totally-not-a-model-xyz). It may not exist or you may not have access to it. Run --model to pick a different model."}"#;
480        assert_eq!(classify_failure(1, bad_model, ""), None);
481    }
482
483    #[test]
484    fn classify_model_access_403_is_not_auth() {
485        // Even when a model-access failure carries a 403/forbidden
486        // status, the model guard must win over `InvalidCredentials`.
487        assert_eq!(
488            classify_failure(
489                1,
490                "",
491                "API Error: 403 Forbidden permission_error: you may not have access to model claude-x"
492            ),
493            None
494        );
495        assert_eq!(
496            classify_failure(1, "", "404 not_found_error: model claude-x does not exist"),
497            None
498        );
499    }
500
501    #[test]
502    fn classify_not_logged_in_bare_is_not_authenticated() {
503        // `--bare` with no API key prints this to stdout with an empty
504        // stderr; it is a genuine missing-credential failure and must
505        // surface as auth (regression for #632).
506        assert_eq!(
507            classify_failure(1, "Not logged in ยท Please run /login", ""),
508            Some(AuthErrorKind::NotAuthenticated)
509        );
510    }
511
512    #[test]
513    fn classify_rate_limit_takes_precedence_over_invalid_creds() {
514        // Some API responses include "401-like" wording in their rate
515        // limit messages; rate_limit should win.
516        assert_eq!(
517            classify_failure(1, "", "Rate limit exceeded. Please wait."),
518            Some(AuthErrorKind::RateLimit)
519        );
520        assert_eq!(
521            classify_failure(1, "", "HTTP 429 Too Many Requests"),
522            Some(AuthErrorKind::RateLimit)
523        );
524        assert_eq!(
525            classify_failure(1, "", "quota exceeded for this account"),
526            Some(AuthErrorKind::RateLimit)
527        );
528    }
529
530    #[test]
531    fn classify_provider_error_when_bedrock_plus_auth_signal() {
532        assert_eq!(
533            classify_failure(
534                1,
535                "",
536                "Bedrock auth failed: AWS credentials not found in chain"
537            ),
538            Some(AuthErrorKind::ProviderError)
539        );
540        assert_eq!(
541            classify_failure(
542                1,
543                "",
544                "Vertex unauthorized -- check GOOGLE_APPLICATION_CREDENTIALS"
545            ),
546            Some(AuthErrorKind::ProviderError)
547        );
548    }
549
550    #[test]
551    fn classify_falls_back_to_other_for_bare_auth_mention() {
552        assert_eq!(
553            classify_failure(1, "", "auth subsystem returned an unexpected error"),
554            Some(AuthErrorKind::Other)
555        );
556    }
557
558    #[test]
559    fn classify_does_not_match_auth_in_stdout_only() {
560        // The fallback "Other" bucket only fires on stderr -- otherwise
561        // a CLI that just happened to print "--allowed-tools auth_helper"
562        // would be misclassified.
563        assert_eq!(
564            classify_failure(0, "auth_helper enabled, all clear", ""),
565            None
566        );
567    }
568
569    #[test]
570    fn classify_examines_stdout_for_specific_patterns() {
571        // Specific patterns work in either stream -- some CLIs print
572        // their auth diagnostics to stdout.
573        assert_eq!(
574            classify_failure(1, "Invalid API key", ""),
575            Some(AuthErrorKind::InvalidCredentials)
576        );
577    }
578
579    #[test]
580    fn auth_error_kind_as_str_matches_serde_repr() {
581        for k in [
582            AuthErrorKind::NotAuthenticated,
583            AuthErrorKind::Expired,
584            AuthErrorKind::InvalidCredentials,
585            AuthErrorKind::RateLimit,
586            AuthErrorKind::ProviderError,
587            AuthErrorKind::Other,
588        ] {
589            let json = serde_json::to_string(&k).expect("serialize");
590            assert_eq!(json, format!("\"{}\"", k.as_str()));
591        }
592    }
593
594    #[test]
595    fn as_str_matches_serde_repr() {
596        assert_eq!(AuthStrategy::Bedrock.as_str(), "bedrock");
597        assert_eq!(AuthStrategy::Vertex.as_str(), "vertex");
598        assert_eq!(AuthStrategy::ApiKey.as_str(), "api_key");
599        assert_eq!(AuthStrategy::OauthToken.as_str(), "oauth_token");
600        assert_eq!(AuthStrategy::Subscription.as_str(), "subscription");
601
602        // serde_json serialization must match -- this is the value we
603        // ship over MCP, so don't let it drift from as_str.
604        for s in [
605            AuthStrategy::Bedrock,
606            AuthStrategy::Vertex,
607            AuthStrategy::ApiKey,
608            AuthStrategy::OauthToken,
609            AuthStrategy::Subscription,
610        ] {
611            let json = serde_json::to_string(&s).expect("serialize");
612            assert_eq!(json, format!("\"{}\"", s.as_str()));
613        }
614    }
615}