Skip to main content

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 Connectors → Accounts.
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 docs/specs/archive/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
54/// Shared HTTP client for outbound provider calls (key validation, model
55/// catalog fetch). Reused by `backend::model_catalog` so we don't build a
56/// parallel `reqwest::Client` singleton (reagent #1923 P2).
57pub(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
68/// Validate `key` for `provider`. Makes one outbound HTTPS request. Returns
69/// a non-`valid` outcome (never errors the RPC) so the caller can surface a
70/// structured message and stay in the entry state.
71pub 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    // Scopes come back in a response header, not the body.
100    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    // Slack returns 200 with { ok: false, error } for bad tokens.
183    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}