Skip to main content

nils_common/provider_runtime/
auth.rs

1use std::path::Path;
2
3use serde_json::Value;
4
5use super::error::CoreError;
6use super::json;
7use super::jwt;
8
9pub fn identity_from_auth_file(path: &Path) -> Result<Option<String>, CoreError> {
10    let value = json::read_json(path)?;
11    let payload = decoded_payload_from_auth_json(&value);
12    Ok(identity_from_auth_json(payload.as_ref()))
13}
14
15pub fn email_from_auth_file(path: &Path) -> Result<Option<String>, CoreError> {
16    let value = json::read_json(path)?;
17    let payload = decoded_payload_from_auth_json(&value);
18    Ok(email_from_auth_json(payload.as_ref()))
19}
20
21pub fn account_id_from_auth_file(path: &Path) -> Result<Option<String>, CoreError> {
22    let value = json::read_json(path)?;
23    let payload = decoded_payload_from_auth_json(&value);
24    Ok(account_id_from_auth_json(&value, payload.as_ref()))
25}
26
27pub fn last_refresh_from_auth_file(path: &Path) -> Result<Option<String>, CoreError> {
28    let value = json::read_json(path)?;
29    Ok(json::string_at(&value, &["last_refresh"]))
30}
31
32pub fn identity_key_from_auth_file(path: &Path) -> Result<Option<String>, CoreError> {
33    let value = json::read_json(path)?;
34    let payload = decoded_payload_from_auth_json(&value);
35    let identity = identity_from_auth_json(payload.as_ref());
36    let identity = match identity {
37        Some(value) => value,
38        None => return Ok(None),
39    };
40    let account_id = account_id_from_auth_json(&value, payload.as_ref());
41    let key = match account_id {
42        Some(account) => format!("{}::{}", identity, account),
43        None => identity,
44    };
45    Ok(Some(key))
46}
47
48pub fn resolve_secret_file_by_email(secret_dir: &Path, target: &str) -> SecretFileResolution {
49    let query = target.to_lowercase();
50    let want_full = target.contains('@');
51
52    let mut matches = Vec::new();
53    if let Ok(entries) = std::fs::read_dir(secret_dir) {
54        for entry in entries.flatten() {
55            let path = entry.path();
56            if path.extension().and_then(|s| s.to_str()) != Some("json") {
57                continue;
58            }
59
60            let email = match email_from_auth_file(&path) {
61                Ok(Some(value)) => value,
62                _ => continue,
63            };
64            let email_lower = email.to_lowercase();
65            if want_full {
66                if email_lower == query {
67                    matches.push(file_name(&path));
68                }
69            } else if let Some(local_part) = email_lower.split('@').next()
70                && local_part == query
71            {
72                matches.push(file_name(&path));
73            }
74        }
75    }
76    matches.sort();
77
78    if matches.len() == 1 {
79        SecretFileResolution::Exact(matches.remove(0))
80    } else if matches.is_empty() {
81        SecretFileResolution::NotFound
82    } else {
83        SecretFileResolution::Ambiguous {
84            candidates: matches,
85        }
86    }
87}
88
89pub fn token_from_auth_json(value: &serde_json::Value) -> Option<String> {
90    json::string_at(value, &["tokens", "id_token"])
91        .or_else(|| json::string_at(value, &["id_token"]))
92        .or_else(|| json::string_at(value, &["tokens", "access_token"]))
93        .or_else(|| json::string_at(value, &["access_token"]))
94}
95
96fn decoded_payload_from_auth_json(value: &Value) -> Option<Value> {
97    let token = token_from_auth_json(value)?;
98    jwt::decode_payload_json(&token)
99}
100
101fn identity_from_auth_json(payload: Option<&Value>) -> Option<String> {
102    payload.and_then(jwt::identity_from_payload)
103}
104
105fn email_from_auth_json(payload: Option<&Value>) -> Option<String> {
106    payload.and_then(jwt::email_from_payload)
107}
108
109fn account_id_from_auth_json(value: &Value, payload: Option<&Value>) -> Option<String> {
110    json::string_at(value, &["tokens", "account_id"])
111        .or_else(|| json::string_at(value, &["account_id"]))
112        .or_else(|| {
113            payload
114                .and_then(|decoded| decoded.get("sub"))
115                .and_then(|sub| sub.as_str())
116                .map(json::strip_newlines)
117        })
118}
119
120fn file_name(path: &Path) -> String {
121    path.file_name()
122        .and_then(|name| name.to_str())
123        .unwrap_or_default()
124        .to_string()
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub enum SecretFileResolution {
129    Exact(String),
130    Ambiguous { candidates: Vec<String> },
131    NotFound,
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use base64::Engine;
138    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
139    use std::fs;
140    use tempfile::TempDir;
141
142    fn write_auth_json(path: &Path, contents: &str) {
143        fs::write(path, contents).expect("write auth json");
144    }
145
146    fn jwt(payload: Value) -> String {
147        let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none","typ":"JWT"}"#);
148        let payload = URL_SAFE_NO_PAD.encode(payload.to_string());
149        format!("{header}.{payload}.sig")
150    }
151
152    #[test]
153    fn account_id_falls_back_to_token_sub_when_fields_missing() {
154        let dir = TempDir::new().expect("tempdir");
155        let path = dir.path().join("auth.json");
156        let token = jwt(serde_json::json!({"sub":"acct_123"}));
157
158        write_auth_json(&path, &format!(r#"{{"tokens":{{"id_token":"{token}"}}}}"#));
159
160        assert_eq!(
161            account_id_from_auth_file(&path).expect("account id"),
162            Some("acct_123".to_string())
163        );
164    }
165
166    #[test]
167    fn identity_key_prefers_account_id_from_json_over_jwt_subject() {
168        let dir = TempDir::new().expect("tempdir");
169        let path = dir.path().join("auth.json");
170        let token = jwt(serde_json::json!({
171            "sub": "sub_from_jwt",
172            "https://api.openai.com/auth": {"chatgpt_user_id":"user_456"}
173        }));
174
175        write_auth_json(
176            &path,
177            &format!(r#"{{"tokens":{{"id_token":"{token}","account_id":"acct_999"}}}}"#),
178        );
179
180        assert_eq!(
181            identity_key_from_auth_file(&path).expect("identity key"),
182            Some("user_456::acct_999".to_string())
183        );
184    }
185
186    #[test]
187    fn identity_key_is_none_when_identity_is_missing() {
188        let dir = TempDir::new().expect("tempdir");
189        let path = dir.path().join("auth.json");
190        write_auth_json(&path, r#"{"tokens":{"account_id":"acct_100"}}"#);
191
192        assert_eq!(
193            identity_key_from_auth_file(&path).expect("identity key"),
194            None
195        );
196    }
197
198    #[test]
199    fn resolve_secret_file_by_email_supports_full_and_local_part_lookup() {
200        let dir = TempDir::new().expect("tempdir");
201        write_auth_json(
202            &dir.path().join("alpha.json"),
203            &auth_json_for_email("alpha@example.com"),
204        );
205        write_auth_json(
206            &dir.path().join("beta.json"),
207            &auth_json_for_email("beta@example.com"),
208        );
209        write_auth_json(&dir.path().join("notes.txt"), "not json");
210
211        assert_eq!(
212            resolve_secret_file_by_email(dir.path(), "alpha@example.com"),
213            SecretFileResolution::Exact("alpha.json".to_string())
214        );
215        assert_eq!(
216            resolve_secret_file_by_email(dir.path(), "beta"),
217            SecretFileResolution::Exact("beta.json".to_string())
218        );
219    }
220
221    #[test]
222    fn resolve_secret_file_by_email_reports_ambiguous_and_not_found() {
223        let dir = TempDir::new().expect("tempdir");
224        write_auth_json(
225            &dir.path().join("alpha-1.json"),
226            &auth_json_for_email("alpha@example.com"),
227        );
228        write_auth_json(
229            &dir.path().join("alpha-2.json"),
230            &auth_json_for_email("alpha@example.com"),
231        );
232
233        match resolve_secret_file_by_email(dir.path(), "alpha@example.com") {
234            SecretFileResolution::Ambiguous { candidates } => {
235                assert_eq!(
236                    candidates,
237                    vec!["alpha-1.json".to_string(), "alpha-2.json".to_string()]
238                );
239            }
240            other => panic!("expected ambiguous match, got {other:?}"),
241        }
242
243        assert_eq!(
244            resolve_secret_file_by_email(dir.path(), "missing@example.com"),
245            SecretFileResolution::NotFound
246        );
247    }
248
249    fn auth_json_for_email(email: &str) -> String {
250        let token = jwt(serde_json::json!({ "email": email }));
251        format!(r#"{{"tokens":{{"id_token":"{token}"}}}}"#)
252    }
253}