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
54fn client() -> &'static reqwest::Client {
55 static C: OnceLock<reqwest::Client> = OnceLock::new();
56 C.get_or_init(|| {
57 reqwest::Client::builder()
58 .timeout(Duration::from_secs(15))
59 .user_agent("AgentMux")
60 .build()
61 .expect("reqwest client build failed")
62 })
63}
64
65pub async fn validate(provider: &str, key: &str) -> ValidationOutcome {
69 let tail = masked_tail(key);
70 match provider {
71 "github" => github(key, tail).await,
72 "openai" => openai(key, tail).await,
73 "anthropic" => anthropic(key, tail).await,
74 "slack" => slack(key, tail).await,
75 other => ValidationOutcome::invalid(
76 tail,
77 format!("no validator for provider '{other}' — save without validating"),
78 ),
79 }
80}
81
82async fn github(key: &str, tail: String) -> ValidationOutcome {
83 let resp = match client()
84 .get("https://api.github.com/user")
85 .header("Authorization", format!("Bearer {key}"))
86 .header("Accept", "application/vnd.github+json")
87 .send()
88 .await
89 {
90 Ok(r) => r,
91 Err(e) => return ValidationOutcome::invalid(tail, format!("network error: {e}")),
92 };
93 if !resp.status().is_success() {
94 return ValidationOutcome::invalid(tail, format!("GitHub rejected the token ({})", resp.status()));
95 }
96 let scopes = resp
98 .headers()
99 .get("x-oauth-scopes")
100 .and_then(|v| v.to_str().ok())
101 .map(|s| {
102 s.split(',')
103 .map(|p| p.trim().to_string())
104 .filter(|p| !p.is_empty())
105 .collect::<Vec<_>>()
106 })
107 .unwrap_or_default();
108 let body: serde_json::Value = resp.json().await.unwrap_or_else(|_| json!({}));
109 let login = body.get("login").and_then(|v| v.as_str()).unwrap_or("");
110 ValidationOutcome {
111 valid: true,
112 metadata: json!({ "github_username": login, "github_scopes": scopes }),
113 masked_tail: tail,
114 error: None,
115 }
116}
117
118async fn openai(key: &str, tail: String) -> ValidationOutcome {
119 let resp = match client()
120 .get("https://api.openai.com/v1/models")
121 .header("Authorization", format!("Bearer {key}"))
122 .send()
123 .await
124 {
125 Ok(r) => r,
126 Err(e) => return ValidationOutcome::invalid(tail, format!("network error: {e}")),
127 };
128 if !resp.status().is_success() {
129 return ValidationOutcome::invalid(tail, format!("OpenAI rejected the key ({})", resp.status()));
130 }
131 let body: serde_json::Value = resp.json().await.unwrap_or_else(|_| json!({}));
132 let model_count = body.get("data").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0);
133 ValidationOutcome {
134 valid: true,
135 metadata: json!({ "openai_model_count": model_count }),
136 masked_tail: tail,
137 error: None,
138 }
139}
140
141async fn anthropic(key: &str, tail: String) -> ValidationOutcome {
142 let resp = match client()
143 .get("https://api.anthropic.com/v1/models")
144 .header("x-api-key", key)
145 .header("anthropic-version", "2023-06-01")
146 .send()
147 .await
148 {
149 Ok(r) => r,
150 Err(e) => return ValidationOutcome::invalid(tail, format!("network error: {e}")),
151 };
152 if !resp.status().is_success() {
153 return ValidationOutcome::invalid(tail, format!("Anthropic rejected the key ({})", resp.status()));
154 }
155 let body: serde_json::Value = resp.json().await.unwrap_or_else(|_| json!({}));
156 let model_count = body.get("data").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0);
157 ValidationOutcome {
158 valid: true,
159 metadata: json!({ "anthropic_model_count": model_count }),
160 masked_tail: tail,
161 error: None,
162 }
163}
164
165async fn slack(key: &str, tail: String) -> ValidationOutcome {
166 let resp = match client()
167 .post("https://slack.com/api/auth.test")
168 .header("Authorization", format!("Bearer {key}"))
169 .send()
170 .await
171 {
172 Ok(r) => r,
173 Err(e) => return ValidationOutcome::invalid(tail, format!("network error: {e}")),
174 };
175 let body: serde_json::Value = match resp.json().await {
176 Ok(v) => v,
177 Err(e) => return ValidationOutcome::invalid(tail, format!("bad response: {e}")),
178 };
179 if !body.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) {
181 let err = body.get("error").and_then(|v| v.as_str()).unwrap_or("invalid token");
182 return ValidationOutcome::invalid(tail, format!("Slack rejected the token: {err}"));
183 }
184 let team = body.get("team").and_then(|v| v.as_str()).unwrap_or("");
185 let user = body.get("user").and_then(|v| v.as_str()).unwrap_or("");
186 ValidationOutcome {
187 valid: true,
188 metadata: json!({ "slack_team": team, "slack_user": user }),
189 masked_tail: tail,
190 error: None,
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 #[test]
199 fn masks_all_but_last_four() {
200 assert_eq!(masked_tail("ghp_abcdef123456"), "••••••••3456");
201 }
202
203 #[test]
204 fn short_keys_fully_masked_no_tail_leak() {
205 assert_eq!(masked_tail("ab"), "••••");
206 assert_eq!(masked_tail("abcd"), "••••");
207 }
208}