Skip to main content

crates_docs/server/auth/
api_key_provider.rs

1//! In-process API-key authentication provider for the HTTP/SSE transport.
2//!
3//! This bridges the project's [`ApiKeyConfig`] to the rust-mcp-sdk
4//! [`AuthProvider`](rust_mcp_sdk::auth::AuthProvider) trait so the SDK's built-in
5//! `AuthMiddleware` enforces API keys on every MCP request (the `/health`
6//! endpoint is intentionally left open for monitoring).
7//!
8//! # Bearer only
9//!
10//! The SDK middleware understands **only** `Authorization: Bearer <token>` — it
11//! cannot read the configured `X-API-Key` header or a query parameter. Callers
12//! talking to the server directly must therefore send the key as a Bearer token:
13//!
14//! ```text
15//! Authorization: Bearer sk_live_xxx
16//! ```
17//!
18//! To keep using `X-API-Key` (and to add TLS), put the bundled reverse-proxy
19//! config (`docs/reverse-proxy/`) in front of the server: it terminates TLS and
20//! rewrites `X-API-Key: <k>` into `Authorization: Bearer <k>` for the backend.
21
22use std::collections::HashMap;
23use std::sync::Arc;
24use std::time::{Duration, SystemTime};
25
26use async_trait::async_trait;
27use rust_mcp_sdk::auth::{
28    AuthInfo, AuthProvider as SdkAuthProvider, AuthenticationError, OauthEndpoint,
29};
30use rust_mcp_sdk::mcp_http::{GenericBody, McpAppState};
31use rust_mcp_sdk::mcp_server::error::TransportServerError;
32
33use crate::server::auth::ApiKeyConfig;
34
35/// Synthetic expiry handed to the SDK middleware for accepted API keys.
36///
37/// The SDK rejects any `AuthInfo` whose `expires_at` is `None` ("Token has no
38/// expiration time") or already in the past. API keys are long-lived
39/// credentials with no intrinsic expiry, so we report a fixed far-future
40/// instant (~10 years, i.e. 24 × 365 × 10 hours). Revocation is performed by
41/// removing the key from configuration and restarting the server — never by
42/// token expiry — which matches the existing hot-reload security note in
43/// `serve_cmd`.
44const API_KEY_TTL_SECS: u64 = 87_600 * 60 * 60;
45
46/// Opaque, non-secret identifier reported for every accepted API key.
47///
48/// `AuthInfo` is attached to the request/session and may be logged, so we must
49/// never place the raw key here. API-key auth carries no per-user identity, so a
50/// constant tag is sufficient and leaks nothing.
51const API_KEY_TOKEN_ID: &str = "api-key";
52
53/// Adapts the project's [`ApiKeyConfig`] to the rust-mcp-sdk `AuthProvider`
54/// trait, enabling in-process Bearer-token enforcement of API keys.
55pub struct ApiKeyAuthProvider {
56    config: ApiKeyConfig,
57}
58
59impl ApiKeyAuthProvider {
60    /// Create a provider backed by the given API-key configuration.
61    #[must_use]
62    pub fn new(config: ApiKeyConfig) -> Self {
63        Self { config }
64    }
65}
66
67#[async_trait]
68impl SdkAuthProvider for ApiKeyAuthProvider {
69    async fn verify_token(&self, access_token: String) -> Result<AuthInfo, AuthenticationError> {
70        // Fail closed, independent of how the provider was constructed. Today the
71        // provider is only built when `enabled` is true (see
72        // `transport::build_api_key_auth`), but `ApiKeyConfig::is_valid_key`
73        // returns `true` for *any* token when `!enabled`. Guarding here ensures a
74        // future caller that constructs the provider unconditionally (e.g. to
75        // support toggling `enabled` for hot-reload) can never silently become a
76        // blanket auth bypass.
77        if !self.config.enabled {
78            return Err(AuthenticationError::InvalidToken {
79                description: "API key authentication is disabled",
80            });
81        }
82
83        // Reject empty / whitespace-only bearer tokens up front. No generated API
84        // key is empty, and this closes a configuration footgun: a stray
85        // `keys = [""]` entry would otherwise let `Authorization: Bearer ` (an
86        // empty token) authenticate via the plaintext fallback.
87        if access_token.trim().is_empty() {
88            return Err(AuthenticationError::InvalidToken {
89                description: "Empty API key",
90            });
91        }
92
93        // `is_valid_key` performs the actual (constant-time) verification against
94        // the configured Argon2 / legacy / plaintext key material.
95        if self.config.is_valid_key(&access_token) {
96            Ok(AuthInfo {
97                token_unique_id: API_KEY_TOKEN_ID.to_string(),
98                client_id: None,
99                user_id: None,
100                scopes: None,
101                expires_at: Some(SystemTime::now() + Duration::from_secs(API_KEY_TTL_SECS)),
102                audience: None,
103                extra: None,
104            })
105        } else {
106            Err(AuthenticationError::InvalidToken {
107                description: "Invalid API key",
108            })
109        }
110    }
111
112    fn auth_endpoints(&self) -> Option<&HashMap<String, OauthEndpoint>> {
113        // API-key auth mounts no OAuth routes, so the SDK never invokes
114        // `handle_request` below.
115        None
116    }
117
118    async fn handle_request(
119        &self,
120        _request: http::Request<&str>,
121        _state: Arc<McpAppState>,
122    ) -> Result<http::Response<GenericBody>, TransportServerError> {
123        // Unreachable: `auth_endpoints` returns `None`, so no route is mounted
124        // that would dispatch here. Return an explicit error in case it ever is.
125        Err(TransportServerError::HttpError(
126            "API-key authentication exposes no OAuth endpoints".to_string(),
127        ))
128    }
129
130    fn protected_resource_metadata_url(&self) -> Option<&str> {
131        None
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    /// Build an enabled config holding the Argon2 hash of a freshly generated
140    /// key, returning both the config and the plain-text key to present.
141    fn config_with_key() -> (ApiKeyConfig, String) {
142        let generated = ApiKeyConfig::default()
143            .generate_key()
144            .expect("failed to generate API key");
145        (
146            ApiKeyConfig {
147                enabled: true,
148                keys: vec![generated.hash],
149                ..Default::default()
150            },
151            generated.key,
152        )
153    }
154
155    #[tokio::test]
156    async fn verify_token_accepts_valid_key_with_future_expiry() {
157        let (config, key) = config_with_key();
158        let provider = ApiKeyAuthProvider::new(config);
159
160        let info = provider
161            .verify_token(key)
162            .await
163            .expect("valid key should be accepted");
164
165        // The SDK middleware requires a present, future expiry.
166        let expires_at = info.expires_at.expect("expires_at must be Some");
167        assert!(expires_at > SystemTime::now());
168        // The raw key must never be echoed back as the token identifier.
169        assert_eq!(info.token_unique_id, API_KEY_TOKEN_ID);
170    }
171
172    #[tokio::test]
173    async fn verify_token_rejects_invalid_key() {
174        let (config, _key) = config_with_key();
175        let provider = ApiKeyAuthProvider::new(config);
176
177        let err = provider
178            .verify_token("not-a-valid-key".to_string())
179            .await
180            .expect_err("invalid key should be rejected");
181
182        assert!(matches!(err, AuthenticationError::InvalidToken { .. }));
183    }
184
185    #[tokio::test]
186    async fn verify_token_rejects_empty_token() {
187        let (config, _key) = config_with_key();
188        let provider = ApiKeyAuthProvider::new(config);
189
190        // Empty and whitespace-only tokens must never authenticate, regardless of
191        // configured key material (guards the `keys = [""]` plaintext footgun).
192        for token in ["", "   "] {
193            let err = provider
194                .verify_token(token.to_string())
195                .await
196                .expect_err("empty token should be rejected");
197            assert!(matches!(err, AuthenticationError::InvalidToken { .. }));
198        }
199    }
200
201    #[tokio::test]
202    async fn verify_token_rejects_valid_key_when_disabled() {
203        // Even a genuinely valid key must be rejected when the config is disabled:
204        // the provider fails closed rather than relying on its caller to never
205        // build it while `enabled == false`.
206        let (mut config, key) = config_with_key();
207        config.enabled = false;
208        let provider = ApiKeyAuthProvider::new(config);
209
210        let err = provider
211            .verify_token(key)
212            .await
213            .expect_err("a disabled provider must reject every token");
214
215        assert!(matches!(err, AuthenticationError::InvalidToken { .. }));
216    }
217
218    #[test]
219    fn exposes_no_oauth_surface() {
220        let (config, _key) = config_with_key();
221        let provider = ApiKeyAuthProvider::new(config);
222
223        assert!(provider.auth_endpoints().is_none());
224        assert!(provider.protected_resource_metadata_url().is_none());
225    }
226}