agentmux_srv/identity/
key_validator.rs1use std::sync::OnceLock;
16use std::time::Duration;
17
18use serde_json::json;
19
20#[derive(Debug, Clone)]
24pub struct ValidationOutcome {
25 pub valid: bool,
26 pub metadata: serde_json::Value,
27 pub masked_tail: String,
28 pub error: Option<String>,
29}
30
31impl ValidationOutcome {
32 fn invalid(masked_tail: String, error: impl Into<String>) -> Self {
33 Self {
34 valid: false,
35 metadata: json!({}),
36 masked_tail,
37 error: Some(error.into()),
38 }
39 }
40}
41
42pub fn masked_tail(secret: &str) -> String {
46 let n = secret.chars().count();
47 if n <= 4 {
48 return "•".repeat(n.max(4));
49 }
50 let tail: String = secret.chars().skip(n - 4).collect();
51 format!("••••••••{tail}")
52}
53
54pub(crate) fn client() -> &'static reqwest::Client {
58 static C: OnceLock<reqwest::Client> = OnceLock::new();
59 C.get_or_init(|| {
60 reqwest::Client::builder()
61 .timeout(Duration::from_secs(15))
62 .user_agent("AgentMux")
63 .build()
64 .expect("reqwest client build failed")
65 })
66}
67
68pub async fn validate(provider: &str, key: &str) -> ValidationOutcome {
72 let tail = masked_tail(key);
73 match provider {
74 "github" => github(key, tail).await,
75 "openai" => openai(key, tail).await,
76 "anthropic" => anthropic(key, tail).await,
77 "slack" => slack(key, tail).await,
78 other => ValidationOutcome::invalid(
79 tail,
80 format!("no validator for provider '{other}' — save without validating"),
81 ),
82 }
83}
84
85async fn github(key: &str, tail: String) -> ValidationOutcome {
86 let resp = match client()
87 .get("https://api.github.com/user")
88 .header("Authorization", format!("Bearer {key}"))
89 .header("Accept", "application/vnd.github+json")
90 .send()
91 .await
92 {
93 Ok(r) => r,
94 Err(e) => return ValidationOutcome::invalid(tail, format!("network error: {e}")),
95 };
96 if !resp.status().is_success() {
97 return ValidationOutcome::invalid(tail, format!("GitHub rejected the token ({})", resp.status()));
98 }
99 let scopes = resp
101 .headers()
102 .get("x-oauth-scopes")
103 .and_then(|v| v.to_str().ok())
104 .map(|s| {
105 s.split(',')
106 .map(|p| p.trim().to_string())
107 .filter(|p| !p.is_empty())
108 .collect::<Vec<_>>()
109 })
110 .unwrap_or_default();
111 let body: serde_json::Value = resp.json().await.unwrap_or_else(|_| json!({}));
112 let login = body.get("login").and_then(|v| v.as_str()).unwrap_or("");
113 ValidationOutcome {
114 valid: true,
115 metadata: json!({ "github_username": login, "github_scopes": scopes }),
116 masked_tail: tail,
117 error: None,
118 }
119}
120
121async fn openai(key: &str, tail: String) -> ValidationOutcome {
122 let resp = match client()
123 .get("https://api.openai.com/v1/models")
124 .header("Authorization", format!("Bearer {key}"))
125 .send()
126 .await
127 {
128 Ok(r) => r,
129 Err(e) => return ValidationOutcome::invalid(tail, format!("network error: {e}")),
130 };
131 if !resp.status().is_success() {
132 return ValidationOutcome::invalid(tail, format!("OpenAI rejected the key ({})", resp.status()));
133 }
134 let body: serde_json::Value = resp.json().await.unwrap_or_else(|_| json!({}));
135 let model_count = body.get("data").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0);
136 ValidationOutcome {
137 valid: true,
138 metadata: json!({ "openai_model_count": model_count }),
139 masked_tail: tail,
140 error: None,
141 }
142}
143
144async fn anthropic(key: &str, tail: String) -> ValidationOutcome {
145 let resp = match client()
146 .get("https://api.anthropic.com/v1/models")
147 .header("x-api-key", key)
148 .header("anthropic-version", "2023-06-01")
149 .send()
150 .await
151 {
152 Ok(r) => r,
153 Err(e) => return ValidationOutcome::invalid(tail, format!("network error: {e}")),
154 };
155 if !resp.status().is_success() {
156 return ValidationOutcome::invalid(tail, format!("Anthropic rejected the key ({})", resp.status()));
157 }
158 let body: serde_json::Value = resp.json().await.unwrap_or_else(|_| json!({}));
159 let model_count = body.get("data").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0);
160 ValidationOutcome {
161 valid: true,
162 metadata: json!({ "anthropic_model_count": model_count }),
163 masked_tail: tail,
164 error: None,
165 }
166}
167
168async fn slack(key: &str, tail: String) -> ValidationOutcome {
169 let resp = match client()
170 .post("https://slack.com/api/auth.test")
171 .header("Authorization", format!("Bearer {key}"))
172 .send()
173 .await
174 {
175 Ok(r) => r,
176 Err(e) => return ValidationOutcome::invalid(tail, format!("network error: {e}")),
177 };
178 let body: serde_json::Value = match resp.json().await {
179 Ok(v) => v,
180 Err(e) => return ValidationOutcome::invalid(tail, format!("bad response: {e}")),
181 };
182 if !body.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) {
184 let err = body.get("error").and_then(|v| v.as_str()).unwrap_or("invalid token");
185 return ValidationOutcome::invalid(tail, format!("Slack rejected the token: {err}"));
186 }
187 let team = body.get("team").and_then(|v| v.as_str()).unwrap_or("");
188 let user = body.get("user").and_then(|v| v.as_str()).unwrap_or("");
189 ValidationOutcome {
190 valid: true,
191 metadata: json!({ "slack_team": team, "slack_user": user }),
192 masked_tail: tail,
193 error: None,
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 #[test]
202 fn masks_all_but_last_four() {
203 assert_eq!(masked_tail("ghp_abcdef123456"), "••••••••3456");
204 }
205
206 #[test]
207 fn short_keys_fully_masked_no_tail_leak() {
208 assert_eq!(masked_tail("ab"), "••••");
209 assert_eq!(masked_tail("abcd"), "••••");
210 }
211}