github-mcp 0.1.0

GitHub v3 REST API MCP server, generated by mcpify.
Documentation
// GitHub v3 REST API MCP server — generated by mcpify. Do not hand-edit.

use crate::core::config_schema::{AuthMethod, Transport};
use crate::core::credential_storage::{load_credential, save_credential};

use super::auth_strategy::{AuthConfig, AuthStrategy, Credentials};
use super::errors::AuthError;
use super::request_credentials::RequestCredentials;
use super::strategies::api_key::ApiKeyStrategy;
use super::strategies::basic::BasicAuthStrategy;
use super::strategies::pat::PatAuthStrategy;
use super::strategies::stub::StubAuthStrategy;

const CREDENTIAL_ACCOUNT: &str = "active-credentials";

fn strategy_for(auth_method: AuthMethod) -> Box<dyn AuthStrategy> {
    match auth_method {
        AuthMethod::Basic => Box::new(BasicAuthStrategy),
        AuthMethod::ApiKey => Box::new(ApiKeyStrategy),
        AuthMethod::Pat => Box::new(PatAuthStrategy),
        #[allow(unreachable_patterns)]
        _ => Box::new(StubAuthStrategy),
    }
}

/// Where `auth_method`'s credential value travels on the wire — used only
/// by the HTTP-transport auth-gate/extractor (`http::auth_extractor`) to
/// know which incoming header to read; stdio never calls this.
pub fn header_location_for(auth_method: AuthMethod) -> (&'static str, &'static str) {
    match auth_method {
        AuthMethod::Pat => ("header", "Authorization"),
        AuthMethod::ApiKey => ("header", "Authorization"),
        AuthMethod::Basic => ("header", "Authorization"),
    }
}

/// Selects exactly one active auth strategy per deployment, chosen via the
/// `auth_method` config value (REQ-1.2.3) — there is no runtime engine for
/// resolving multiple simultaneously-required schemes.
pub struct AuthManager {
    auth_method: AuthMethod,
    strategy: Box<dyn AuthStrategy>,
    cached_credentials: Option<Credentials>,
}

impl AuthManager {
    pub fn new(auth_method: AuthMethod) -> Self {
        Self {
            strategy: strategy_for(auth_method),
            auth_method,
            cached_credentials: None,
        }
    }

    pub async fn login(&mut self, config: &AuthConfig) -> anyhow::Result<Credentials> {
        let credentials = self.strategy.authenticate(config).await?;
        self.cached_credentials = Some(credentials.clone());
        save_credential(CREDENTIAL_ACCOUNT, &serde_json::to_string(&credentials)?)?;
        Ok(credentials)
    }

    /// Seeds in-memory credentials directly, bypassing OS-keychain/file
    /// storage entirely — for tests (which must never touch the real OS
    /// keychain as a side effect of running) and for callers that already
    /// hold validated credentials from elsewhere.
    pub fn set_credentials(&mut self, credentials: Credentials) {
        self.cached_credentials = Some(credentials);
    }

    pub async fn credentials(&mut self) -> anyhow::Result<Credentials> {
        if let Some(cached) = &self.cached_credentials
            && self.strategy.validate_credentials(cached)
        {
            return Ok(cached.clone());
        }

        if let Some(stored) = load_credential(CREDENTIAL_ACCOUNT)? {
            let parsed: Credentials = serde_json::from_str(&stored)?;
            if self.strategy.validate_credentials(&parsed) {
                self.cached_credentials = Some(parsed.clone());
                return Ok(parsed);
            }
            if let Ok(refreshed) = self.strategy.refresh_token(&parsed).await {
                self.cached_credentials = Some(refreshed.clone());
                save_credential(CREDENTIAL_ACCOUNT, &serde_json::to_string(&refreshed)?)?;
                return Ok(refreshed);
            }
        }

        Err(AuthError::NoActiveCredentials(format!("{:?}", self.auth_method)).into())
    }

    /// Decorates outgoing request headers with credentials for `transport`:
    /// on `Transport::Http`, `request_override` (extracted per-request from
    /// the caller's own headers — see `http::auth_extractor`) is the *only*
    /// source ever consulted, since HTTP deployments must never leak the
    /// operator's local config/keychain secrets to a caller who didn't
    /// supply their own; a missing override is a hard error, not a
    /// fallback. On `Transport::Stdio`, `request_override` is ignored and
    /// behavior is unchanged from before HTTP-transport support existed:
    /// `self.credentials()` (config cascade + keychain).
    ///
    /// API-key placement (header vs. query, and the header/param name)
    /// defaults to an `X-Api-Key` header here; a scheme declaring a
    /// different location is one of Story R6's api-client responsibilities
    /// to refine, not this manager's — `request_override`'s
    /// `request_header_name`/`request_header_value` pair is the one
    /// exception, since the HTTP path already knows the scheme's declared
    /// header name (`RsAuthSchemeView::header_name`) at generation time.
    pub async fn apply_auth_headers(
        &mut self,
        mut headers: std::collections::HashMap<String, String>,
        method: &str,
        url: &str,
        transport: Transport,
        request_override: Option<&RequestCredentials>,
    ) -> anyhow::Result<std::collections::HashMap<String, String>> {
        let credentials = match (transport, request_override) {
            (Transport::Http, Some(override_credentials)) => {
                override_credentials.clone().into_credentials_map()
            }
            (Transport::Http, None) => {
                anyhow::bail!(
                    "no credentials were supplied on this HTTP request; local config/env/keychain \
                     is never used as a fallback over HTTP transport"
                );
            }
            (Transport::Stdio, _) => self.credentials().await?,
        };
        // `method`/`url` are part of this method's fixed public signature
        // (every active strategy's header-decoration needs them in
        // principle), but only OAuth1's per-request signing actually
        // consumes them — unused here since OAuth1 wasn't discovered for
        // this deployment.
        let _ = method;
        let _ = url;

        if let Some(name) = credentials.get("request_header_name") {
            let value = credentials
                .get("request_header_value")
                .cloned()
                .unwrap_or_default();
            headers.insert(name.clone(), value);
        } else if let Some(header) = credentials.get("authorization_header") {
            headers.insert("Authorization".to_string(), header.clone());
        } else if let Some(api_key) = credentials.get("api_key") {
            headers.insert("X-Api-Key".to_string(), api_key.clone());
        }
        Ok(headers)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn seeded_credentials_win_over_stored_ones() {
        let mut manager = AuthManager::new(AuthMethod::Pat);
        let mut credentials = Credentials::new();
        // Seeded with whatever field(s) `auth_methods.0`'s own
        // `validate_credentials` actually checks for — each strategy
        // checks a different key (see auth/strategies/*.rs), so this has
        // to match the specific first-discovered method, not just
        // "oauth1 or not".
        credentials.insert("token".to_string(), "abc".to_string());
        manager.set_credentials(credentials.clone());

        let resolved = manager.credentials().await.unwrap();
        assert_eq!(resolved, credentials);
    }
    #[tokio::test]
    async fn http_transport_uses_the_request_override_not_the_config_cascade() {
        let mut manager = AuthManager::new(AuthMethod::Pat);
        // Deliberately seed a *different* credential than the override below,
        // so the assertion fails if HTTP transport ever falls back to it.
        let mut stale = Credentials::new();
        stale.insert(
            "request_header_name".to_string(),
            "X-Should-Not-Be-Used".to_string(),
        );
        manager.set_credentials(stale);

        let override_credentials = RequestCredentials {
            header_name: "X-Api-Key".to_string(),
            value: "from-the-request".to_string(),
        };
        let headers = manager
            .apply_auth_headers(
                std::collections::HashMap::new(),
                "GET",
                "https://example.com",
                Transport::Http,
                Some(&override_credentials),
            )
            .await
            .unwrap();

        assert_eq!(
            headers.get("X-Api-Key").map(String::as_str),
            Some("from-the-request")
        );
    }

    #[tokio::test]
    async fn http_transport_without_an_override_errors_instead_of_falling_back() {
        let mut manager = AuthManager::new(AuthMethod::Pat);
        let mut stale = Credentials::new();
        stale.insert(
            "request_header_name".to_string(),
            "X-Should-Not-Be-Used".to_string(),
        );
        manager.set_credentials(stale);

        let result = manager
            .apply_auth_headers(
                std::collections::HashMap::new(),
                "GET",
                "https://example.com",
                Transport::Http,
                None,
            )
            .await;

        assert!(result.is_err());
    }
}