github_mcp/auth/
auth_manager.rs1use crate::core::config_schema::{AuthMethod, Transport};
4use crate::core::credential_storage::{load_credential, save_credential};
5
6use super::auth_strategy::{AuthConfig, AuthStrategy, Credentials};
7use super::errors::AuthError;
8use super::request_credentials::RequestCredentials;
9use super::strategies::api_key::ApiKeyStrategy;
10use super::strategies::basic::BasicAuthStrategy;
11use super::strategies::pat::PatAuthStrategy;
12use super::strategies::stub::StubAuthStrategy;
13
14const CREDENTIAL_ACCOUNT: &str = "active-credentials";
15const ENV_PREFIX: &str = "GITHUB_MCP";
16
17fn credentials_from_env(auth_method: AuthMethod) -> Option<Credentials> {
24 match auth_method {
25 AuthMethod::Pat => {
26 let token = std::env::var(format!("{ENV_PREFIX}_TOKEN"))
27 .or_else(|_| std::env::var(format!("{ENV_PREFIX}_API_KEY")))
28 .ok()?;
29 let mut credentials = Credentials::new();
30 credentials.insert("token".to_string(), token);
31 Some(credentials)
32 }
33 AuthMethod::ApiKey => {
34 let api_key = std::env::var(format!("{ENV_PREFIX}_API_KEY"))
35 .or_else(|_| std::env::var(format!("{ENV_PREFIX}_TOKEN")))
36 .ok()?;
37 let mut credentials = Credentials::new();
38 credentials.insert("api_key".to_string(), api_key);
39 Some(credentials)
40 }
41 AuthMethod::Basic => {
42 let username = std::env::var(format!("{ENV_PREFIX}_USERNAME")).ok()?;
43 let password = std::env::var(format!("{ENV_PREFIX}_PASSWORD")).ok()?;
44 let mut credentials = Credentials::new();
45 credentials.insert("username".to_string(), username);
46 credentials.insert("password".to_string(), password);
47 Some(credentials)
48 }
49 }
50}
51
52fn strategy_for(auth_method: AuthMethod) -> Box<dyn AuthStrategy> {
53 match auth_method {
54 AuthMethod::Basic => Box::new(BasicAuthStrategy),
55 AuthMethod::ApiKey => Box::new(ApiKeyStrategy),
56 AuthMethod::Pat => Box::new(PatAuthStrategy),
57 #[allow(unreachable_patterns)]
58 _ => Box::new(StubAuthStrategy),
59 }
60}
61
62pub fn header_location_for(auth_method: AuthMethod) -> (&'static str, &'static str) {
66 match auth_method {
67 AuthMethod::Pat => ("header", "Authorization"),
68 AuthMethod::ApiKey => ("header", "Authorization"),
69 AuthMethod::Basic => ("header", "Authorization"),
70 }
71}
72
73pub struct AuthManager {
77 auth_method: AuthMethod,
78 strategy: Box<dyn AuthStrategy>,
79 cached_credentials: Option<Credentials>,
80}
81
82impl AuthManager {
83 pub fn new(auth_method: AuthMethod) -> Self {
84 Self {
85 strategy: strategy_for(auth_method),
86 auth_method,
87 cached_credentials: None,
88 }
89 }
90
91 pub async fn login(&mut self, config: &AuthConfig) -> anyhow::Result<Credentials> {
92 let credentials = self.strategy.authenticate(config).await?;
93 self.cached_credentials = Some(credentials.clone());
94 save_credential(CREDENTIAL_ACCOUNT, &serde_json::to_string(&credentials)?)?;
95 Ok(credentials)
96 }
97
98 pub fn set_credentials(&mut self, credentials: Credentials) {
103 self.cached_credentials = Some(credentials);
104 }
105
106 pub async fn credentials(&mut self) -> anyhow::Result<Credentials> {
107 if let Some(cached) = &self.cached_credentials
108 && self.strategy.validate_credentials(cached)
109 {
110 return self.normalize_credentials(cached).await;
111 }
112
113 if let Some(from_env) = credentials_from_env(self.auth_method)
114 && self.strategy.validate_credentials(&from_env)
115 {
116 let normalized = self.normalize_credentials(&from_env).await?;
117 self.cached_credentials = Some(normalized.clone());
118 return Ok(normalized);
119 }
120
121 if let Some(stored) = load_credential(CREDENTIAL_ACCOUNT)? {
122 let parsed: Credentials = serde_json::from_str(&stored)?;
123 if self.strategy.validate_credentials(&parsed) {
124 let normalized = self.normalize_credentials(&parsed).await?;
125 self.cached_credentials = Some(normalized.clone());
126 save_credential(CREDENTIAL_ACCOUNT, &serde_json::to_string(&normalized)?)?;
127 return Ok(normalized);
128 }
129 let exchanged = if parsed.contains_key("refresh_token") {
136 self.strategy.refresh_token(&parsed).await
137 } else {
138 self.strategy.authenticate(&parsed).await
139 };
140 if let Ok(exchanged) = exchanged {
141 let normalized = self.normalize_credentials(&exchanged).await?;
142 self.cached_credentials = Some(normalized.clone());
143 save_credential(CREDENTIAL_ACCOUNT, &serde_json::to_string(&normalized)?)?;
144 return Ok(normalized);
145 }
146 }
147
148 Err(AuthError::NoActiveCredentials(format!("{:?}", self.auth_method)).into())
149 }
150
151 async fn normalize_credentials(
152 &self,
153 credentials: &Credentials,
154 ) -> anyhow::Result<Credentials> {
155 if credentials.contains_key("authorization_header")
156 || credentials.contains_key("api_key")
157 || credentials.contains_key("request_header_name")
158 {
159 return Ok(credentials.clone());
160 }
161
162 self.strategy.authenticate(credentials).await
163 }
164
165 pub async fn apply_auth_headers(
183 &mut self,
184 mut headers: std::collections::HashMap<String, String>,
185 method: &str,
186 url: &str,
187 transport: Transport,
188 request_override: Option<&RequestCredentials>,
189 ) -> anyhow::Result<std::collections::HashMap<String, String>> {
190 let credentials = match (transport, request_override) {
191 (Transport::Http, Some(override_credentials)) => {
192 override_credentials.clone().into_credentials_map()
193 }
194 (Transport::Http, None) => {
195 anyhow::bail!(
196 "no credentials were supplied on this HTTP request; local config/env/keychain \
197 is never used as a fallback over HTTP transport"
198 );
199 }
200 (Transport::Stdio, _) => self.credentials().await?,
201 };
202 let _ = method;
208 let _ = url;
209
210 if let Some(name) = credentials.get("request_header_name") {
211 let value = credentials
212 .get("request_header_value")
213 .cloned()
214 .unwrap_or_default();
215 headers.insert(name.clone(), value);
216 } else if let Some(header) = credentials.get("authorization_header") {
217 headers.insert("Authorization".to_string(), header.clone());
218 } else if let Some(api_key) = credentials.get("api_key") {
219 let header_name = credentials
228 .get("request_header_name")
229 .cloned()
230 .unwrap_or_else(|| "X-Api-Key".to_string());
231 headers.insert(header_name, api_key.clone());
232 }
233 Ok(headers)
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240
241 #[tokio::test]
242 async fn seeded_credentials_win_over_stored_ones() {
243 let mut manager = AuthManager::new(AuthMethod::Pat);
244 let mut credentials = Credentials::new();
245 credentials.insert("token".to_string(), "abc".to_string());
251 manager.set_credentials(credentials.clone());
252
253 let resolved = manager.credentials().await.unwrap();
254 assert_eq!(resolved.get("token"), credentials.get("token"));
255 assert_eq!(
256 resolved.get("authorization_header").map(String::as_str),
257 Some("Bearer abc")
258 );
259 }
260
261 #[tokio::test]
262 async fn pat_credentials_saved_by_setup_are_normalized_before_use() {
263 let mut manager = AuthManager::new(AuthMethod::Pat);
264 let mut credentials = Credentials::new();
265 credentials.insert("token".to_string(), "abc".to_string());
266 manager.set_credentials(credentials);
267
268 let headers = manager
269 .apply_auth_headers(
270 std::collections::HashMap::new(),
271 "GET",
272 "https://api.github.com/user",
273 Transport::Stdio,
274 None,
275 )
276 .await
277 .unwrap();
278
279 assert_eq!(
280 headers.get("Authorization").map(String::as_str),
281 Some("Bearer abc")
282 );
283 }
284 #[tokio::test]
285 async fn http_transport_uses_the_request_override_not_the_config_cascade() {
286 let mut manager = AuthManager::new(AuthMethod::Pat);
287 let mut stale = Credentials::new();
290 stale.insert(
291 "request_header_name".to_string(),
292 "X-Should-Not-Be-Used".to_string(),
293 );
294 manager.set_credentials(stale);
295
296 let override_credentials = RequestCredentials {
297 header_name: "X-Api-Key".to_string(),
298 value: "from-the-request".to_string(),
299 };
300 let headers = manager
301 .apply_auth_headers(
302 std::collections::HashMap::new(),
303 "GET",
304 "https://example.com",
305 Transport::Http,
306 Some(&override_credentials),
307 )
308 .await
309 .unwrap();
310
311 assert_eq!(
312 headers.get("X-Api-Key").map(String::as_str),
313 Some("from-the-request")
314 );
315 }
316
317 #[tokio::test]
318 async fn http_transport_without_an_override_errors_instead_of_falling_back() {
319 let mut manager = AuthManager::new(AuthMethod::Pat);
320 let mut stale = Credentials::new();
321 stale.insert(
322 "request_header_name".to_string(),
323 "X-Should-Not-Be-Used".to_string(),
324 );
325 manager.set_credentials(stale);
326
327 let result = manager
328 .apply_auth_headers(
329 std::collections::HashMap::new(),
330 "GET",
331 "https://example.com",
332 Transport::Http,
333 None,
334 )
335 .await;
336
337 assert!(result.is_err());
338 }
339}