1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4
5use super::ConfigError;
6use crate::process::guard::SpawnGuard;
7use crate::process::stdio::StdioProcessSpec;
8use crate::security::redact::{is_sensitive_key, redact_stdio_args, redact_url};
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11pub struct UpstreamConfig {
12 pub name: String,
13 #[serde(default = "default_true")]
14 pub enabled: bool,
15 #[serde(default)]
16 pub url: Option<String>,
17 #[serde(default)]
18 pub bearer_token_env: Option<String>,
19 #[serde(default)]
20 pub command: Option<String>,
21 #[serde(default)]
22 pub args: Vec<String>,
23 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
24 pub env: BTreeMap<String, String>,
25 #[serde(default)]
26 pub oauth: Option<GatewayUpstreamOauthConfig>,
27 #[serde(default = "default_true")]
28 pub proxy_resources: bool,
29 #[serde(default = "default_true")]
30 pub proxy_prompts: bool,
31 #[serde(default)]
32 pub expose_tools: Option<Vec<String>>,
33 #[serde(default)]
34 pub expose_resources: Option<Vec<String>>,
35 #[serde(default)]
36 pub expose_prompts: Option<Vec<String>>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct UpstreamConfigView {
41 pub name: String,
42 pub enabled: bool,
43 pub url: Option<String>,
44 pub bearer_token_env: Option<String>,
45 pub command: Option<String>,
46 pub args: Vec<String>,
47 pub env_keys: Vec<String>,
48 pub oauth_enabled: bool,
49 pub proxy_resources: bool,
50 pub proxy_prompts: bool,
51 pub expose_tools: Option<Vec<String>>,
52 pub expose_resources: Option<Vec<String>>,
53 pub expose_prompts: Option<Vec<String>>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct GatewayUpstreamOauthConfig {
58 pub mode: GatewayUpstreamOauthMode,
59 #[serde(default)]
60 pub registration: GatewayUpstreamOauthRegistration,
61 #[serde(default)]
62 pub scopes: Option<Vec<String>>,
63 #[serde(default)]
64 pub prefer_client_metadata_document: Option<bool>,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub enum GatewayUpstreamOauthMode {
70 AuthorizationCodePkce,
71}
72
73#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(tag = "strategy", rename_all = "snake_case")]
75pub enum GatewayUpstreamOauthRegistration {
76 #[default]
79 Auto,
80 ClientMetadataDocument {
81 url: String,
82 },
83 Preregistered {
84 client_id: String,
85 #[serde(default)]
86 client_secret_env: Option<String>,
87 },
88 Dynamic,
89}
90
91impl Default for UpstreamConfig {
92 fn default() -> Self {
93 Self {
94 name: String::new(),
95 enabled: true,
96 url: None,
97 bearer_token_env: None,
98 command: None,
99 args: Vec::new(),
100 env: BTreeMap::new(),
101 oauth: None,
102 proxy_resources: true,
103 proxy_prompts: true,
104 expose_tools: None,
105 expose_resources: None,
106 expose_prompts: None,
107 }
108 }
109}
110
111impl UpstreamConfig {
112 pub fn validate(&self) -> Result<(), ConfigError> {
113 validate_name(&self.name)?;
114 validate_transport_shape(self)?;
115 if let Some(name) = self.bearer_token_env.as_deref() {
116 validate_bearer_token_env(name)?;
117 }
118 if let Some(url) = self.url.as_deref() {
119 let parsed = url::Url::parse(url)
120 .map_err(|_| ConfigError::invalid("url", "must be a valid URL"))?;
121 match parsed.scheme() {
122 "http" | "https" | "ws" | "wss" => {}
123 _ => {
124 return Err(ConfigError::invalid(
125 "url",
126 "must use http, https, ws, or wss",
127 ));
128 }
129 }
130 }
131 if let Some(oauth) = &self.oauth {
132 oauth.validate()?;
133 if self.url.is_none() {
134 return Err(ConfigError::invalid("oauth", "requires upstream url"));
135 }
136 }
137 Ok(())
138 }
139
140 #[must_use]
141 pub fn redacted_view(&self) -> UpstreamConfigView {
142 UpstreamConfigView {
143 name: self.name.clone(),
144 enabled: self.enabled,
145 url: self.url.as_deref().map(redact_url),
146 bearer_token_env: self
147 .bearer_token_env
148 .as_ref()
149 .map(|_| "[redacted]".to_owned()),
150 command: self.command.clone(),
151 args: redact_stdio_args(&self.args),
152 env_keys: self
153 .env
154 .keys()
155 .map(|key| {
156 if is_sensitive_key(key) {
157 "[redacted]".to_owned()
158 } else {
159 key.clone()
160 }
161 })
162 .collect(),
163 oauth_enabled: self.oauth.is_some(),
164 proxy_resources: self.proxy_resources,
165 proxy_prompts: self.proxy_prompts,
166 expose_tools: self.expose_tools.clone(),
167 expose_resources: self.expose_resources.clone(),
168 expose_prompts: self.expose_prompts.clone(),
169 }
170 }
171}
172
173fn validate_transport_shape(config: &UpstreamConfig) -> Result<(), ConfigError> {
174 let has_url = config
175 .url
176 .as_deref()
177 .map(str::trim)
178 .is_some_and(|url| !url.is_empty());
179 let has_command = config
180 .command
181 .as_deref()
182 .map(str::trim)
183 .is_some_and(|command| !command.is_empty());
184 match (has_url, has_command) {
185 (true, true) => {
186 return Err(ConfigError::invalid(
187 "transport",
188 "must specify either url or command, not both",
189 ));
190 }
191 (false, false) => {
192 return Err(ConfigError::invalid(
193 "transport",
194 "must specify exactly one of url or command",
195 ));
196 }
197 _ => {}
198 }
199 if has_command {
200 let spec = StdioProcessSpec {
201 command: config.command.clone().unwrap_or_default(),
202 args: config.args.clone(),
203 env: config.env.clone(),
204 };
205 spec.validate(&SpawnGuard::default())
206 .map_err(|_| ConfigError::invalid("command", "stdio command is not allowed"))?;
207 }
208 Ok(())
209}
210
211impl GatewayUpstreamOauthConfig {
212 fn validate(&self) -> Result<(), ConfigError> {
213 if let Some(scopes) = &self.scopes
214 && scopes.iter().any(|scope| scope.trim().is_empty())
215 {
216 return Err(ConfigError::invalid(
217 "oauth.scopes",
218 "must not contain blanks",
219 ));
220 }
221 if let GatewayUpstreamOauthRegistration::Preregistered {
222 client_secret_env: Some(env),
223 ..
224 } = &self.registration
225 {
226 validate_bearer_token_env(env)?;
227 }
228 Ok(())
229 }
230}
231
232pub fn default_true() -> bool {
233 true
234}
235
236fn validate_name(name: &str) -> Result<(), ConfigError> {
237 if name.trim().is_empty() {
238 return Err(ConfigError::invalid("name", "must not be empty"));
239 }
240 if !name
241 .chars()
242 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
243 {
244 return Err(ConfigError::invalid(
245 "name",
246 "must contain only ASCII letters, digits, hyphens, underscores, and dots",
247 ));
248 }
249 Ok(())
250}
251
252pub fn validate_bearer_token_env(value: &str) -> Result<(), ConfigError> {
253 let trimmed = value.trim();
254 let looks_like_secret = trimmed.starts_with("Bearer ")
255 || trimmed.starts_with("sk-")
256 || trimmed.starts_with("ghp_")
257 || trimmed.starts_with("github_pat_")
258 || looks_like_jwt(trimmed);
259 if looks_like_secret {
260 return Err(ConfigError::invalid(
261 "bearer_token_env",
262 "must be an environment variable name, not a token value",
263 ));
264 }
265 let mut chars = trimmed.chars();
266 let Some(first) = chars.next() else {
267 return Err(ConfigError::invalid(
268 "bearer_token_env",
269 "must not be empty",
270 ));
271 };
272 if !(first == '_' || first.is_ascii_uppercase()) {
273 return Err(ConfigError::invalid(
274 "bearer_token_env",
275 "must start with an uppercase ASCII letter or underscore",
276 ));
277 }
278 if !chars.all(|ch| ch == '_' || ch.is_ascii_uppercase() || ch.is_ascii_digit()) {
279 return Err(ConfigError::invalid(
280 "bearer_token_env",
281 "must contain only uppercase ASCII letters, digits, and underscores",
282 ));
283 }
284 Ok(())
285}
286
287fn looks_like_jwt(value: &str) -> bool {
288 let parts: Vec<&str> = value.split('.').collect();
289 parts.len() == 3 && parts.iter().all(|part| part.len() >= 8)
290}
291
292#[cfg(test)]
293#[path = "config_tests.rs"]
294mod tests;