agentmux_srv\identity/
key_validator.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Live API-key validation for the Trust Center.
5//!
6//! Each supported service has a probe that makes a single minimal
7//! authenticated request and maps the response to non-secret metadata
8//! (account name, scopes, etc.). This is the only outbound call the key
9//! flow makes, and it fires only on the user's explicit Validate click
10//! (see specs/SPEC_TRUST_CENTER_2026_06_15.md §5.1, §6).
11//!
12//! The plaintext key is passed in by value, used to build one request, and
13//! never logged. Callers must keep it out of logs/transcripts.
14
15use std::sync::OnceLock;
16use std::time::Duration;
17
18use serde_json::json;
19
20/// Outcome of a validation probe. `metadata` is non-secret JSON safe to
21/// persist on the account row + show in the UI; `masked_tail` is the
22/// last few chars for the locked display.
23#[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
42/// Masked hint for the locked display: bullet run + last 4 chars. Stored
43/// alongside the account so the panel can render `••••••••3f9a` without the
44/// secret. Keys shorter than 4 chars are fully masked (no tail leak).
45pub 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
65/// Validate `key` for `provider`. Makes one outbound HTTPS request. Returns
66/// a non-`valid` outcome (never errors the RPC) so the caller can surface a
67/// structured message and stay in the entry state.
68pub 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    // Scopes come back in a response header, not the body.
97    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    // Slack returns 200 with { ok: false, error } for bad tokens.
180    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}