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 Ok(cached.clone());
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                self.cached_credentials = Some(parsed.clone());
81                return Ok(parsed);
82            }
83            if let Ok(refreshed) = self.strategy.refresh_token(&parsed).await {
84                self.cached_credentials = Some(refreshed.clone());
85                save_credential(CREDENTIAL_ACCOUNT, &serde_json::to_string(&refreshed)?)?;
86                return Ok(refreshed);
87            }
88        }
89
90        Err(AuthError::NoActiveCredentials(format!("{:?}", self.auth_method)).into())
91    }
92
93    /// Decorates outgoing request headers with credentials for `transport`:
94    /// on `Transport::Http`, `request_override` (extracted per-request from
95    /// the caller's own headers — see `http::auth_extractor`) is the *only*
96    /// source ever consulted, since HTTP deployments must never leak the
97    /// operator's local config/keychain secrets to a caller who didn't
98    /// supply their own; a missing override is a hard error, not a
99    /// fallback. On `Transport::Stdio`, `request_override` is ignored and
100    /// behavior is unchanged from before HTTP-transport support existed:
101    /// `self.credentials()` (config cascade + keychain).
102    ///
103    /// API-key placement (header vs. query, and the header/param name)
104    /// defaults to an `X-Api-Key` header here; a scheme declaring a
105    /// different location is one of Story R6's api-client responsibilities
106    /// to refine, not this manager's — `request_override`'s
107    /// `request_header_name`/`request_header_value` pair is the one
108    /// exception, since the HTTP path already knows the scheme's declared
109    /// header name (`RsAuthSchemeView::header_name`) at generation time.
110    pub async fn apply_auth_headers(
111        &mut self,
112        mut headers: std::collections::HashMap<String, String>,
113        method: &str,
114        url: &str,
115        transport: Transport,
116        request_override: Option<&RequestCredentials>,
117    ) -> anyhow::Result<std::collections::HashMap<String, String>> {
118        let credentials = match (transport, request_override) {
119            (Transport::Http, Some(override_credentials)) => {
120                override_credentials.clone().into_credentials_map()
121            }
122            (Transport::Http, None) => {
123                anyhow::bail!(
124                    "no credentials were supplied on this HTTP request; local config/env/keychain \
125                     is never used as a fallback over HTTP transport"
126                );
127            }
128            (Transport::Stdio, _) => self.credentials().await?,
129        };
130        // `method`/`url` are part of this method's fixed public signature
131        // (every active strategy's header-decoration needs them in
132        // principle), but only OAuth1's per-request signing actually
133        // consumes them — unused here since OAuth1 wasn't discovered for
134        // this deployment.
135        let _ = method;
136        let _ = url;
137
138        if let Some(name) = credentials.get("request_header_name") {
139            let value = credentials
140                .get("request_header_value")
141                .cloned()
142                .unwrap_or_default();
143            headers.insert(name.clone(), value);
144        } else if let Some(header) = credentials.get("authorization_header") {
145            headers.insert("Authorization".to_string(), header.clone());
146        } else if let Some(api_key) = credentials.get("api_key") {
147            headers.insert("X-Api-Key".to_string(), api_key.clone());
148        }
149        Ok(headers)
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[tokio::test]
158    async fn seeded_credentials_win_over_stored_ones() {
159        let mut manager = AuthManager::new(AuthMethod::Pat);
160        let mut credentials = Credentials::new();
161        // Seeded with whatever field(s) `auth_methods.0`'s own
162        // `validate_credentials` actually checks for — each strategy
163        // checks a different key (see auth/strategies/*.rs), so this has
164        // to match the specific first-discovered method, not just
165        // "oauth1 or not".
166        credentials.insert("token".to_string(), "abc".to_string());
167        manager.set_credentials(credentials.clone());
168
169        let resolved = manager.credentials().await.unwrap();
170        assert_eq!(resolved, credentials);
171    }
172    #[tokio::test]
173    async fn http_transport_uses_the_request_override_not_the_config_cascade() {
174        let mut manager = AuthManager::new(AuthMethod::Pat);
175        // Deliberately seed a *different* credential than the override below,
176        // so the assertion fails if HTTP transport ever falls back to it.
177        let mut stale = Credentials::new();
178        stale.insert(
179            "request_header_name".to_string(),
180            "X-Should-Not-Be-Used".to_string(),
181        );
182        manager.set_credentials(stale);
183
184        let override_credentials = RequestCredentials {
185            header_name: "X-Api-Key".to_string(),
186            value: "from-the-request".to_string(),
187        };
188        let headers = manager
189            .apply_auth_headers(
190                std::collections::HashMap::new(),
191                "GET",
192                "https://example.com",
193                Transport::Http,
194                Some(&override_credentials),
195            )
196            .await
197            .unwrap();
198
199        assert_eq!(
200            headers.get("X-Api-Key").map(String::as_str),
201            Some("from-the-request")
202        );
203    }
204
205    #[tokio::test]
206    async fn http_transport_without_an_override_errors_instead_of_falling_back() {
207        let mut manager = AuthManager::new(AuthMethod::Pat);
208        let mut stale = Credentials::new();
209        stale.insert(
210            "request_header_name".to_string(),
211            "X-Should-Not-Be-Used".to_string(),
212        );
213        manager.set_credentials(stale);
214
215        let result = manager
216            .apply_auth_headers(
217                std::collections::HashMap::new(),
218                "GET",
219                "https://example.com",
220                Transport::Http,
221                None,
222            )
223            .await;
224
225        assert!(result.is_err());
226    }
227}