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}