Skip to main content

opencode_provider_manager/auth/
parser.rs

1//! Parser for OpenCode's auth.json file.
2//!
3//! Format: { "provider_id": { "type": "api", "key": "sk-..." } }
4//! Also supports: { "provider_id": { "type": "oauth", "token": "..." } }
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::Path;
9
10use super::error::{AuthError, Result};
11
12/// Auth entries parsed from auth.json.
13pub type AuthEntries = HashMap<String, AuthEntry>;
14
15/// A single provider's auth entry.
16#[derive(Clone, Serialize, Deserialize)]
17pub struct AuthEntry {
18    /// Auth type (e.g., "api", "oauth").
19    #[serde(rename = "type")]
20    pub auth_type: String,
21
22    /// API key or token value.
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub key: Option<String>,
25
26    /// OAuth token (for OAuth-based auth).
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub token: Option<String>,
29
30    /// Additional fields we don't explicitly model.
31    #[serde(flatten)]
32    pub extra: HashMap<String, serde_json::Value>,
33}
34
35// Manual Debug impl that redacts sensitive fields to prevent accidental
36// secret exposure in logs or error output.
37impl std::fmt::Debug for AuthEntry {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        f.debug_struct("AuthEntry")
40            .field("auth_type", &self.auth_type)
41            .field("key", &self.key.as_ref().map(|_| "***"))
42            .field("token", &self.token.as_ref().map(|_| "***"))
43            .field("extra", &self.extra)
44            .finish()
45    }
46}
47
48/// Parse auth.json from a file path.
49pub fn parse_auth_file(path: &Path) -> Result<AuthEntries> {
50    if !path.exists() {
51        return Err(AuthError::FileNotFound {
52            path: path.display().to_string(),
53        });
54    }
55
56    let content = std::fs::read_to_string(path)?;
57    parse_auth_json(&content)
58}
59
60/// Parse auth.json from a string.
61/// Supports JSONC (JSON with comments and trailing commas).
62pub fn parse_auth_json(content: &str) -> Result<AuthEntries> {
63    // Try direct JSON first (fast path for well-formed files)
64    if let Ok(entries) = serde_json::from_str::<AuthEntries>(content) {
65        return Ok(entries);
66    }
67
68    // Fall back to JSONC parsing (strips comments and trailing commas)
69    let value = jsonc_parser::parse_to_value(content, &Default::default())
70        .map_err(|e| AuthError::InvalidFormat(format!("JSONC parse error: {e:?}")))?;
71
72    match value {
73        Some(v) => {
74            let serde_value = json_value_to_serde(&v);
75            serde_json::from_value::<AuthEntries>(serde_value)
76                .map_err(|e| AuthError::InvalidFormat(format!("{e}")))
77        }
78        None => Err(AuthError::InvalidFormat("Empty auth document".to_string())),
79    }
80}
81
82/// Convert a jsonc_parser::JsonValue to a serde_json::Value.
83fn json_value_to_serde(value: &jsonc_parser::JsonValue<'_>) -> serde_json::Value {
84    match value {
85        jsonc_parser::JsonValue::Object(obj) => {
86            let mut map = serde_json::Map::new();
87            for (key, val) in obj.clone().into_iter() {
88                map.insert(key, json_value_to_serde(&val));
89            }
90            serde_json::Value::Object(map)
91        }
92        jsonc_parser::JsonValue::Array(arr) => {
93            let values: Vec<serde_json::Value> =
94                arr.iter().map(|v| json_value_to_serde(v)).collect();
95            serde_json::Value::Array(values)
96        }
97        jsonc_parser::JsonValue::Boolean(b) => serde_json::Value::Bool(*b),
98        jsonc_parser::JsonValue::Number(n) => {
99            if let Ok(i) = n.parse::<i64>() {
100                serde_json::Value::Number(i.into())
101            } else if let Ok(f) = n.parse::<f64>() {
102                serde_json::Value::Number(
103                    serde_json::Number::from_f64(f).unwrap_or(serde_json::Number::from(0)),
104                )
105            } else {
106                serde_json::Value::Number(0.into())
107            }
108        }
109        jsonc_parser::JsonValue::String(s) => serde_json::Value::String(s.to_string()),
110        jsonc_parser::JsonValue::Null => serde_json::Value::Null,
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn test_parse_auth_json_api_key() {
120        let json = r#"{
121            "anthropic": {
122                "type": "api",
123                "key": "sk-ant-api03-xxxxx"
124            }
125        }"#;
126
127        let entries = parse_auth_json(json).unwrap();
128        assert!(entries.contains_key("anthropic"));
129        let entry = &entries["anthropic"];
130        assert_eq!(entry.auth_type, "api");
131        assert_eq!(entry.key.as_deref(), Some("sk-ant-api03-xxxxx"));
132    }
133
134    #[test]
135    fn test_parse_auth_json_oauth() {
136        let json = r#"{
137            "github-copilot": {
138                "type": "oauth",
139                "token": "gho_xxxxx"
140            }
141        }"#;
142
143        let entries = parse_auth_json(json).unwrap();
144        assert!(entries.contains_key("github-copilot"));
145        let entry = &entries["github-copilot"];
146        assert_eq!(entry.auth_type, "oauth");
147        assert_eq!(entry.token.as_deref(), Some("gho_xxxxx"));
148    }
149
150    #[test]
151    fn test_parse_auth_json_multiple_providers() {
152        let json = r#"{
153            "anthropic": {
154                "type": "api",
155                "key": "sk-ant-xxx"
156            },
157            "openai": {
158                "type": "api",
159                "key": "sk-xxx"
160            }
161        }"#;
162
163        let entries = parse_auth_json(json).unwrap();
164        assert_eq!(entries.len(), 2);
165        assert!(entries.contains_key("anthropic"));
166        assert!(entries.contains_key("openai"));
167    }
168
169    #[test]
170    fn test_parse_auth_json_empty() {
171        let json = "{}";
172        let entries = parse_auth_json(json).unwrap();
173        assert!(entries.is_empty());
174    }
175
176    #[test]
177    fn test_parse_auth_json_with_comments() {
178        let jsonc = r#"{
179            // This is my Anthropic key
180            "anthropic": {
181                "type": "api",
182                "key": "sk-ant-xxx"
183            }
184        }"#;
185
186        let entries = parse_auth_json(jsonc).unwrap();
187        assert!(entries.contains_key("anthropic"));
188        assert_eq!(entries["anthropic"].auth_type, "api");
189    }
190}