1use std::collections::HashMap;
37
38use serde::Serialize;
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
44#[serde(rename_all = "snake_case")]
45pub enum AuthStrategy {
46 Bedrock,
50 Vertex,
53 ApiKey,
55 OauthToken,
58 Subscription,
63}
64
65impl AuthStrategy {
66 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#[derive(Debug, Clone, Serialize)]
83pub struct AuthSummary {
84 pub strategy: AuthStrategy,
86 pub has_anthropic_api_key: bool,
88 pub has_oauth_token: bool,
90 pub bedrock_enabled: bool,
92 pub vertex_enabled: bool,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
109#[serde(rename_all = "snake_case")]
110pub enum AuthErrorKind {
111 NotAuthenticated,
115 Expired,
118 InvalidCredentials,
122 RateLimit,
126 ProviderError,
130 Other,
135}
136
137impl AuthErrorKind {
138 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
152pub fn classify_failure(_exit_code: i32, stdout: &str, stderr: &str) -> Option<AuthErrorKind> {
174 let combined = format!("{stdout}\n{stderr}").to_ascii_lowercase();
175
176 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 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 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
255pub fn detect() -> AuthSummary {
258 let env: HashMap<String, String> = std::env::vars().collect();
259 detect_from(&env)
260}
261
262pub 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
292fn is_set(value: Option<&str>) -> bool {
294 value.is_some_and(|v| !v.trim().is_empty())
295}
296
297fn 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 #[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 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 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 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 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 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 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 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}