@@ -574,36 +574,112 @@ pub fn check_oauth_file(path: &Path) -> CredentialHealth {
574574}
575575
576576/// Check OAuth credentials from JSON content.
577+ ///
578+ /// Handles multiple credential file formats:
579+ /// - Direct top-level tokens: `{"access_token": "...", "refresh_token": "..."}`
580+ /// - Codex `auth.json` format: `{"tokens": {"access_token": "...", "id_token": "..."}}`
581+ /// or `{"OPENAI_API_KEY": "sk-..."}`
582+ /// - Claude `.credentials.json` format: `{"claudeAiOauth": {"accessToken": "..."}}`
577583#[ must_use]
578584pub fn check_oauth_json ( json : & str ) -> CredentialHealth {
579- #[ derive( Deserialize ) ]
580- #[ allow( clippy:: struct_field_names) ]
581- struct OAuthTokens {
582- #[ serde( default ) ]
583- access_token : Option < String > ,
584- #[ serde( default ) ]
585- id_token : Option < String > ,
586- #[ serde( default ) ]
587- refresh_token : Option < String > ,
588- }
589-
590- // Try to parse as direct tokens
591- let tokens: OAuthTokens = match serde_json:: from_str ( json) {
592- Ok ( t) => t,
585+ // Parse into a generic JSON value first so we can probe multiple shapes
586+ let value: serde_json:: Value = match serde_json:: from_str ( json) {
587+ Ok ( v) => v,
593588 Err ( e) => return CredentialHealth :: CheckFailed ( format ! ( "invalid JSON: {e}" ) ) ,
594589 } ;
595590
591+ // Strategy 1: Direct top-level tokens (generic OAuth files)
592+ // {"access_token": "...", "id_token": "...", "refresh_token": "..."}
593+ {
594+ let access = value
595+ . get ( "access_token" )
596+ . and_then ( |v| v. as_str ( ) )
597+ . filter ( |s| !s. is_empty ( ) ) ;
598+ let id = value
599+ . get ( "id_token" )
600+ . and_then ( |v| v. as_str ( ) )
601+ . filter ( |s| !s. is_empty ( ) ) ;
602+ let refresh = value
603+ . get ( "refresh_token" )
604+ . and_then ( |v| v. as_str ( ) )
605+ . filter ( |s| !s. is_empty ( ) ) ;
606+
607+ if access. is_some ( ) || id. is_some ( ) {
608+ return check_token_triple ( access. or ( id) , refresh) ;
609+ }
610+ }
611+
612+ // Strategy 2: Codex auth.json - tokens nested under "tokens" key
613+ // {"tokens": {"access_token": "...", "id_token": "..."}}
614+ if let Some ( tokens_obj) = value. get ( "tokens" ) {
615+ let access = tokens_obj
616+ . get ( "access_token" )
617+ . and_then ( |v| v. as_str ( ) )
618+ . filter ( |s| !s. is_empty ( ) ) ;
619+ let id = tokens_obj
620+ . get ( "id_token" )
621+ . and_then ( |v| v. as_str ( ) )
622+ . filter ( |s| !s. is_empty ( ) ) ;
623+ let refresh = tokens_obj
624+ . get ( "refresh_token" )
625+ . and_then ( |v| v. as_str ( ) )
626+ . filter ( |s| !s. is_empty ( ) ) ;
627+
628+ if access. is_some ( ) || id. is_some ( ) {
629+ return check_token_triple ( access. or ( id) , refresh) ;
630+ }
631+ }
632+
633+ // Strategy 3: Codex API key auth
634+ // {"OPENAI_API_KEY": "sk-..."}
635+ if value
636+ . get ( "OPENAI_API_KEY" )
637+ . and_then ( |v| v. as_str ( ) )
638+ . is_some_and ( |s| !s. is_empty ( ) )
639+ {
640+ return CredentialHealth :: ApiKeyPresent ;
641+ }
642+
643+ // Also check lowercase variant used by some configs
644+ if value
645+ . get ( "apiKey" )
646+ . and_then ( |v| v. as_str ( ) )
647+ . is_some_and ( |s| !s. is_empty ( ) )
648+ {
649+ return CredentialHealth :: ApiKeyPresent ;
650+ }
651+
652+ // Strategy 4: Claude .credentials.json - tokens under "claudeAiOauth"
653+ // {"claudeAiOauth": {"accessToken": "...", "refreshToken": "..."}}
654+ if let Some ( claude_oauth) = value. get ( "claudeAiOauth" ) {
655+ let access = claude_oauth
656+ . get ( "accessToken" )
657+ . and_then ( |v| v. as_str ( ) )
658+ . filter ( |s| !s. is_empty ( ) ) ;
659+ let refresh = claude_oauth
660+ . get ( "refreshToken" )
661+ . and_then ( |v| v. as_str ( ) )
662+ . filter ( |s| !s. is_empty ( ) ) ;
663+
664+ if access. is_some ( ) {
665+ return check_token_triple ( access, refresh) ;
666+ }
667+ }
668+
669+ // No tokens found in any known format
670+ CredentialHealth :: Missing
671+ }
672+
673+ /// Check a set of extracted token strings and return the appropriate health status.
674+ fn check_token_triple ( access_or_id : Option < & str > , refresh : Option < & str > ) -> CredentialHealth {
596675 let checker = JwtHealthChecker :: new ( ) ;
597676
598- // Check access/id token (use id_token if access_token not present)
599- let access_token = tokens. access_token . as_ref ( ) . or ( tokens. id_token . as_ref ( ) ) ;
600- let access_health = match access_token {
677+ let access_health = match access_or_id {
601678 Some ( token) => checker. check ( token) ,
602679 None => return CredentialHealth :: Missing ,
603680 } ;
604681
605- // Check refresh token if present
606- match tokens. refresh_token . as_ref ( ) {
682+ match refresh {
607683 Some ( refresh_token) => {
608684 let refresh_health = checker. check ( refresh_token) ;
609685 CredentialHealth :: OAuth ( OAuthHealth :: with_refresh ( access_health, refresh_health) )
@@ -1155,6 +1231,74 @@ mod tests {
11551231 assert ! ( matches!( health, CredentialHealth :: CheckFailed ( _) ) ) ;
11561232 }
11571233
1234+ #[ test]
1235+ fn check_oauth_json_codex_nested_tokens ( ) {
1236+ // Codex auth.json nests tokens under "tokens" key
1237+ let id_token = make_jwt_with_exp ( 86400 ) ;
1238+ let json = format ! (
1239+ r#"{{"tokens":{{"id_token":"{id_token}","access_token":"acc","account_id":"acct_123"}}}}"#
1240+ ) ;
1241+
1242+ let health = check_oauth_json ( & json) ;
1243+ assert ! (
1244+ matches!( health, CredentialHealth :: OAuth ( _) ) ,
1245+ "Expected OAuth health for Codex nested tokens, got {health:?}"
1246+ ) ;
1247+ }
1248+
1249+ #[ test]
1250+ fn check_oauth_json_codex_api_key ( ) {
1251+ // Codex auth.json with only OPENAI_API_KEY
1252+ let json = r#"{"OPENAI_API_KEY": "sk-test-key-123"}"# ;
1253+
1254+ let health = check_oauth_json ( json) ;
1255+ assert ! (
1256+ matches!( health, CredentialHealth :: ApiKeyPresent ) ,
1257+ "Expected ApiKeyPresent for Codex API key auth, got {health:?}"
1258+ ) ;
1259+ }
1260+
1261+ #[ test]
1262+ fn check_oauth_json_codex_api_key_lowercase ( ) {
1263+ let json = r#"{"apiKey": "sk-test-key-456"}"# ;
1264+
1265+ let health = check_oauth_json ( json) ;
1266+ assert ! (
1267+ matches!( health, CredentialHealth :: ApiKeyPresent ) ,
1268+ "Expected ApiKeyPresent for apiKey field, got {health:?}"
1269+ ) ;
1270+ }
1271+
1272+ #[ test]
1273+ fn check_oauth_json_claude_credentials ( ) {
1274+ // Claude .credentials.json format
1275+ let access = make_jwt_with_exp ( 86400 ) ;
1276+ let json = format ! (
1277+ r#"{{"claudeAiOauth":{{"accessToken":"{access}","subscriptionType":"team"}}}}"#
1278+ ) ;
1279+
1280+ let health = check_oauth_json ( & json) ;
1281+ assert ! (
1282+ matches!( health, CredentialHealth :: OAuth ( _) ) ,
1283+ "Expected OAuth health for Claude credentials, got {health:?}"
1284+ ) ;
1285+ }
1286+
1287+ #[ test]
1288+ fn check_oauth_json_claude_credentials_with_refresh ( ) {
1289+ let access = make_jwt_with_exp ( 86400 ) ;
1290+ let refresh = make_jwt_with_exp ( 86400 * 30 ) ;
1291+ let json = format ! (
1292+ r#"{{"claudeAiOauth":{{"accessToken":"{access}","refreshToken":"{refresh}","subscriptionType":"pro"}}}}"#
1293+ ) ;
1294+
1295+ let health = check_oauth_json ( & json) ;
1296+ assert ! (
1297+ matches!( health, CredentialHealth :: OAuth ( _) ) ,
1298+ "Expected OAuth health for Claude credentials with refresh, got {health:?}"
1299+ ) ;
1300+ }
1301+
11581302 // =========================================================================
11591303 // Description and severity tests
11601304 // =========================================================================
0 commit comments