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";
15
16fn strategy_for(auth_method: AuthMethod) -> Box<dyn AuthStrategy> {
17    match auth_method {
18        AuthMethod::Basic => Box::new(BasicAuthStrategy),
19        AuthMethod::ApiKey => Box::new(ApiKeyStrategy),
20        AuthMethod::Pat => Box::new(PatAuthStrategy),
21        #[allow(unreachable_patterns)]
22        _ => Box::new(StubAuthStrategy),
23    }
24}
25
26/// Where `auth_method`'s credential value travels on the wire — used only
27/// by the HTTP-transport auth-gate/extractor (`http::auth_extractor`) to
28/// know which incoming header to read; stdio never calls this.
29pub fn header_location_for(auth_method: AuthMethod) -> (&'static str, &'static str) {
30    match auth_method {
31        AuthMethod::Pat => ("header", "Authorization"),
32        AuthMethod::ApiKey => ("header", "Authorization"),
33        AuthMethod::Basic => ("header", "Authorization"),
34    }
35}
36
37/// Selects exactly one active auth strategy per deployment, chosen via the
38/// `auth_method` config value (REQ-1.2.3) — there is no runtime engine for
39/// resolving multiple simultaneously-required schemes.
40pub struct AuthManager {
41    auth_method: AuthMethod,
42    strategy: Box<dyn AuthStrategy>,
43    cached_credentials: Option<Credentials>,
44}
45
46impl AuthManager {
47    pub fn new(auth_method: AuthMethod) -> Self {
48        Self {
49            strategy: strategy_for(auth_method),
50            auth_method,
51            cached_credentials: None,
52        }
53    }
54
55    pub async fn login(&mut self, config: &AuthConfig) -> anyhow::Result<Credentials> {
56        let credentials = self.strategy.authenticate(config).await?;
57        self.cached_credentials = Some(credentials.clone());
58        save_credential(CREDENTIAL_ACCOUNT, &serde_json::to_string(&credentials)?)?;
59        Ok(credentials)
60    }
61
62    /// Seeds in-memory credentials directly, bypassing OS-keychain/file
63    /// storage entirely — for tests (which must never touch the real OS
64    /// keychain as a side effect of running) and for callers that already
65    /// hold validated credentials from elsewhere.
66    pub fn set_credentials(&mut self, credentials: Credentials) {
67        self.cached_credentials = Some(credentials);
68    }
69
70    pub async fn credentials(&mut self) -> anyhow::Result<Credentials> {
71        if let Some(cached) = &self.cached_credentials
72            && self.strategy.validate_credentials(cached)
73        {
74            return self.normalize_credentials(cached).await;
75        }
76
77        if let Some(stored) = load_credential(CREDENTIAL_ACCOUNT)? {
78            let parsed: Credentials = serde_json::from_str(&stored)?;
79            if self.strategy.validate_credentials(&parsed) {
80                let normalized = self.normalize_credentials(&parsed).await?;
81                self.cached_credentials = Some(normalized.clone());
82                save_credential(CREDENTIAL_ACCOUNT, &serde_json::to_string(&normalized)?)?;
83                return Ok(normalized);
84            }
85            if let Ok(refreshed) = self.strategy.refresh_token(&parsed).await {
86                let normalized = self.normalize_credentials(&refreshed).await?;
87                self.cached_credentials = Some(normalized.clone());
88                save_credential(CREDENTIAL_ACCOUNT, &serde_json::to_string(&normalized)?)?;
89                return Ok(normalized);
90            }
91        }
92
93        Err(AuthError::NoActiveCredentials(format!("{:?}", self.auth_method)).into())
94    }
95
96    async fn normalize_credentials(
97        &self,
98        credentials: &Credentials,
99    ) -> anyhow::Result<Credentials> {
100        if credentials.contains_key("authorization_header")
101            || credentials.contains_key("api_key")
102            || credentials.contains_key("request_header_name")
103        {
104            return Ok(credentials.clone());
105        }
106
107        self.strategy.authenticate(credentials).await
108    }
109
110    /// Decorates outgoing request headers with credentials for `transport`:
111    /// on `Transport::Http`, `request_override` (extracted per-request from
112    /// the caller's own headers — see `http::auth_extractor`) is the *only*
113    /// source ever consulted, since HTTP deployments must never leak the
114    /// operator's local config/keychain secrets to a caller who didn't
115    /// supply their own; a missing override is a hard error, not a
116    /// fallback. On `Transport::Stdio`, `request_override` is ignored and
117    /// behavior is unchanged from before HTTP-transport support existed:
118    /// `self.credentials()` (config cascade + keychain).
119    ///
120    /// API-key placement (header vs. query, and the header/param name)
121    /// defaults to an `X-Api-Key` header here; a scheme declaring a
122    /// different location is one of Story R6's api-client responsibilities
123    /// to refine, not this manager's — `request_override`'s
124    /// `request_header_name`/`request_header_value` pair is the one
125    /// exception, since the HTTP path already knows the scheme's declared
126    /// header name (`RsAuthSchemeView::header_name`) at generation time.
127    pub async fn apply_auth_headers(
128        &mut self,
129        mut headers: std::collections::HashMap<String, String>,
130        method: &str,
131        url: &str,
132        transport: Transport,
133        request_override: Option<&RequestCredentials>,
134    ) -> anyhow::Result<std::collections::HashMap<String, String>> {
135        let credentials = match (transport, request_override) {
136            (Transport::Http, Some(override_credentials)) => {
137                override_credentials.clone().into_credentials_map()
138            }
139            (Transport::Http, None) => {
140                anyhow::bail!(
141                    "no credentials were supplied on this HTTP request; local config/env/keychain \
142                     is never used as a fallback over HTTP transport"
143                );
144            }
145            (Transport::Stdio, _) => self.credentials().await?,
146        };
147        // `method`/`url` are part of this method's fixed public signature
148        // (every active strategy's header-decoration needs them in
149        // principle), but only OAuth1's per-request signing actually
150        // consumes them — unused here since OAuth1 wasn't discovered for
151        // this deployment.
152        let _ = method;
153        let _ = url;
154
155        if let Some(name) = credentials.get("request_header_name") {
156            let value = credentials
157                .get("request_header_value")
158                .cloned()
159                .unwrap_or_default();
160            headers.insert(name.clone(), value);
161        } else if let Some(header) = credentials.get("authorization_header") {
162            headers.insert("Authorization".to_string(), header.clone());
163        } else if let Some(api_key) = credentials.get("api_key") {
164            headers.insert("X-Api-Key".to_string(), api_key.clone());
165        }
166        Ok(headers)
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[tokio::test]
175    async fn seeded_credentials_win_over_stored_ones() {
176        let mut manager = AuthManager::new(AuthMethod::Pat);
177        let mut credentials = Credentials::new();
178        // Seeded with whatever field(s) `auth_methods.0`'s own
179        // `validate_credentials` actually checks for — each strategy
180        // checks a different key (see auth/strategies/*.rs), so this has
181        // to match the specific first-discovered method, not just
182        // "oauth1 or not".
183        credentials.insert("token".to_string(), "abc".to_string());
184        manager.set_credentials(credentials.clone());
185
186        let resolved = manager.credentials().await.unwrap();
187        assert_eq!(resolved.get("token"), credentials.get("token"));
188        assert_eq!(
189            resolved.get("authorization_header").map(String::as_str),
190            Some("Bearer abc")
191        );
192    }
193
194    #[tokio::test]
195    async fn pat_credentials_saved_by_setup_are_normalized_before_use() {
196        let mut manager = AuthManager::new(AuthMethod::Pat);
197        let mut credentials = Credentials::new();
198        credentials.insert("token".to_string(), "abc".to_string());
199        manager.set_credentials(credentials);
200
201        let headers = manager
202            .apply_auth_headers(
203                std::collections::HashMap::new(),
204                "GET",
205                "https://api.github.com/user",
206                Transport::Stdio,
207                None,
208            )
209            .await
210            .unwrap();
211
212        assert_eq!(
213            headers.get("Authorization").map(String::as_str),
214            Some("Bearer abc")
215        );
216    }
217    #[tokio::test]
218    async fn http_transport_uses_the_request_override_not_the_config_cascade() {
219        let mut manager = AuthManager::new(AuthMethod::Pat);
220        // Deliberately seed a *different* credential than the override below,
221        // so the assertion fails if HTTP transport ever falls back to it.
222        let mut stale = Credentials::new();
223        stale.insert(
224            "request_header_name".to_string(),
225            "X-Should-Not-Be-Used".to_string(),
226        );
227        manager.set_credentials(stale);
228
229        let override_credentials = RequestCredentials {
230            header_name: "X-Api-Key".to_string(),
231            value: "from-the-request".to_string(),
232        };
233        let headers = manager
234            .apply_auth_headers(
235                std::collections::HashMap::new(),
236                "GET",
237                "https://example.com",
238                Transport::Http,
239                Some(&override_credentials),
240            )
241            .await
242            .unwrap();
243
244        assert_eq!(
245            headers.get("X-Api-Key").map(String::as_str),
246            Some("from-the-request")
247        );
248    }
249
250    #[tokio::test]
251    async fn http_transport_without_an_override_errors_instead_of_falling_back() {
252        let mut manager = AuthManager::new(AuthMethod::Pat);
253        let mut stale = Credentials::new();
254        stale.insert(
255            "request_header_name".to_string(),
256            "X-Should-Not-Be-Used".to_string(),
257        );
258        manager.set_credentials(stale);
259
260        let result = manager
261            .apply_auth_headers(
262                std::collections::HashMap::new(),
263                "GET",
264                "https://example.com",
265                Transport::Http,
266                None,
267            )
268            .await;
269
270        assert!(result.is_err());
271    }
272}