Skip to main content

github_mcp/auth/
auth_manager.rs

1// GitHub v3 REST API MCP server — generated by mcpify. Do not hand-edit.
2
3use 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
17/// Reads the `.env.example`-documented credential override env vars
18/// directly into a `Credentials` map for `auth_method`, so a deployment can
19/// supply credentials purely via environment (e.g. a container's env block)
20/// without ever running `setup` first. Returns `None` when the relevant
21/// var(s) aren't set, so callers fall back to the stored-credential lookup
22/// unchanged.
23fn 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
62/// Where `auth_method`'s credential value travels on the wire — used only
63/// by the HTTP-transport auth-gate/extractor (`http::auth_extractor`) to
64/// know which incoming header to read; stdio never calls this.
65pub 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
73/// Selects exactly one active auth strategy per deployment, chosen via the
74/// `auth_method` config value (REQ-1.2.3) — there is no runtime engine for
75/// resolving multiple simultaneously-required schemes.
76pub 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    /// Seeds in-memory credentials directly, bypassing OS-keychain/file
99    /// storage entirely — for tests (which must never touch the real OS
100    /// keychain as a side effect of running) and for callers that already
101    /// hold validated credentials from elsewhere.
102    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            // A blob with a `refresh_token` has already completed the
130            // initial code exchange at some point (it just expired) — use
131            // the cheap refresh path. A blob with neither `access_token`
132            // nor `refresh_token` is straight out of the setup wizard
133            // (raw `authorization_code`, never exchanged yet), so fall
134            // through to the full exchange instead.
135            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    /// Decorates outgoing request headers with credentials for `transport`:
166    /// on `Transport::Http`, `request_override` (extracted per-request from
167    /// the caller's own headers — see `http::auth_extractor`) is the *only*
168    /// source ever consulted, since HTTP deployments must never leak the
169    /// operator's local config/keychain secrets to a caller who didn't
170    /// supply their own; a missing override is a hard error, not a
171    /// fallback. On `Transport::Stdio`, `request_override` is ignored and
172    /// behavior is unchanged from before HTTP-transport support existed:
173    /// `self.credentials()` (config cascade + keychain).
174    ///
175    /// API-key placement (header vs. query, and the header/param name)
176    /// defaults to an `X-Api-Key` header here; a scheme declaring a
177    /// different location is one of Story R6's api-client responsibilities
178    /// to refine, not this manager's — `request_override`'s
179    /// `request_header_name`/`request_header_value` pair is the one
180    /// exception, since the HTTP path already knows the scheme's declared
181    /// header name (`RsAuthSchemeView::header_name`) at generation time.
182    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        // `method`/`url` are part of this method's fixed public signature
203        // (every active strategy's header-decoration needs them in
204        // principle), but only OAuth1's per-request signing actually
205        // consumes them — unused here since OAuth1 wasn't discovered for
206        // this deployment.
207        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            // GitHub's discovered auth schemes (PAT, Basic) never populate
220            // "api_key"/"request_header_name" — there is no reachable path
221            // to this branch in practice since the setup wizard's ApiKey
222            // option doesn't correspond to a GitHub-supported scheme. Kept
223            // for structural parity with the other mcpify-generated Rust
224            // servers: honor the scheme's real configured header name (via
225            // "request_header_name", the same key checked above) instead of
226            // hardcoding "X-Api-Key".
227            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        // Seeded with whatever field(s) `auth_methods.0`'s own
246        // `validate_credentials` actually checks for — each strategy
247        // checks a different key (see auth/strategies/*.rs), so this has
248        // to match the specific first-discovered method, not just
249        // "oauth1 or not".
250        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        // Deliberately seed a *different* credential than the override below,
288        // so the assertion fails if HTTP transport ever falls back to it.
289        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}