1use std::future::Future;
21use std::pin::Pin;
22
23use oauth2::{CsrfToken, PkceCodeVerifier, TokenResponse as _};
24use rmcp::transport::auth::{
25 AuthError, CredentialStore, StateStore, StoredAuthorizationState, StoredCredentials,
26};
27use rmcp_client as rmcp;
28
29use crate::sqlite::SqliteStore;
30use crate::types::{UpstreamOauthCredentialRow, UpstreamOauthStateRow};
31use crate::upstream::encryption::{self, EncryptionKey};
32
33fn credential_aad(upstream_name: &str, subject: &str, issuer: &str, client_id: &str) -> Vec<u8> {
34 format!("upstream={upstream_name}\0subject={subject}\0issuer={issuer}\0client_id={client_id}")
35 .into_bytes()
36}
37
38fn now_unix() -> i64 {
39 std::time::SystemTime::now()
40 .duration_since(std::time::UNIX_EPOCH)
41 .unwrap_or_default()
42 .as_secs() as i64
43}
44
45pub struct SqliteCredentialStore {
51 store: SqliteStore,
52 key: EncryptionKey,
53 upstream_name: String,
54 subject: String,
55 issuer: String,
56}
57
58impl SqliteCredentialStore {
59 pub fn new(
60 store: SqliteStore,
61 key: EncryptionKey,
62 upstream_name: impl Into<String>,
63 subject: impl Into<String>,
64 issuer: impl Into<String>,
65 ) -> Self {
66 Self {
67 store,
68 key,
69 upstream_name: upstream_name.into(),
70 subject: subject.into(),
71 issuer: issuer.into(),
72 }
73 }
74}
75
76impl CredentialStore for SqliteCredentialStore {
77 fn load<'life0, 'async_trait>(
78 &'life0 self,
79 ) -> Pin<
80 Box<
81 dyn Future<Output = Result<Option<StoredCredentials>, AuthError>> + Send + 'async_trait,
82 >,
83 >
84 where
85 'life0: 'async_trait,
86 Self: 'async_trait,
87 {
88 Box::pin(async move {
89 let row = self
90 .store
91 .find_upstream_oauth_credentials(&self.upstream_name, &self.subject)
92 .await
93 .map_err(|e| AuthError::InternalError(e.to_string()))?;
94
95 let Some(row) = row else {
96 return Ok(None);
97 };
98 if row.issuer.is_empty() || row.issuer != self.issuer {
99 self.store
100 .delete_upstream_oauth_credentials(&self.upstream_name, &self.subject)
101 .await
102 .map_err(|e| AuthError::InternalError(e.to_string()))?;
103 return Ok(None);
104 }
105
106 let aad = credential_aad(
107 &row.upstream_name,
108 &row.subject,
109 &row.issuer,
110 &row.client_id,
111 );
112 let plaintext =
113 encryption::open_with_aad(&self.key, &row.token_blob, &row.token_blob_nonce, &aad)
114 .map_err(|_| AuthError::AuthorizationRequired)?;
115
116 let creds: StoredCredentials = serde_json::from_slice(&plaintext)
117 .map_err(|e| AuthError::InternalError(format!("deserialize credentials: {e}")))?;
118 if creds.issuer.as_deref() != Some(row.issuer.as_str()) {
119 self.store
120 .delete_upstream_oauth_credentials(&self.upstream_name, &self.subject)
121 .await
122 .map_err(|e| AuthError::InternalError(e.to_string()))?;
123 return Ok(None);
124 }
125
126 Ok(Some(creds))
127 })
128 }
129
130 fn save<'life0, 'async_trait>(
131 &'life0 self,
132 credentials: StoredCredentials,
133 ) -> Pin<Box<dyn Future<Output = Result<(), AuthError>> + Send + 'async_trait>>
134 where
135 'life0: 'async_trait,
136 Self: 'async_trait,
137 {
138 Box::pin(async move {
139 let credentials = credentials.with_issuer(Some(self.issuer.clone()));
140 let token_received_at = credentials
141 .token_received_at
142 .map(|t| t as i64)
143 .unwrap_or_else(now_unix);
144
145 let (access_token_expires_at, refresh_token_present) =
146 if let Some(ref token) = credentials.token_response {
147 let expires_in = token.expires_in().map(|d| d.as_secs()).unwrap_or(3600) as i64;
148 (
149 token_received_at + expires_in,
150 token.refresh_token().is_some(),
151 )
152 } else {
153 (0, false)
154 };
155
156 let granted_scopes_json = serde_json::to_string(&credentials.granted_scopes)
157 .map_err(|e| AuthError::InternalError(format!("serialize scopes: {e}")))?;
158
159 let plaintext = serde_json::to_vec(&credentials)
160 .map_err(|e| AuthError::InternalError(format!("serialize credentials: {e}")))?;
161
162 let aad = credential_aad(
163 &self.upstream_name,
164 &self.subject,
165 &self.issuer,
166 &credentials.client_id,
167 );
168 let (token_blob, token_blob_nonce) =
169 encryption::seal_with_aad(&self.key, &plaintext, &aad)
170 .map_err(|e| AuthError::InternalError(format!("encrypt credentials: {e}")))?;
171
172 let row = UpstreamOauthCredentialRow {
173 upstream_name: self.upstream_name.clone(),
174 subject: self.subject.clone(),
175 issuer: self.issuer.clone(),
176 client_id: credentials.client_id.clone(),
177 granted_scopes_json,
178 token_blob,
179 token_blob_nonce,
180 token_received_at,
181 access_token_expires_at,
182 refresh_token_present,
183 };
184
185 self.store
186 .upsert_upstream_oauth_credentials(row)
187 .await
188 .map_err(|e| AuthError::InternalError(e.to_string()))
189 })
190 }
191
192 fn clear<'life0, 'async_trait>(
193 &'life0 self,
194 ) -> Pin<Box<dyn Future<Output = Result<(), AuthError>> + Send + 'async_trait>>
195 where
196 'life0: 'async_trait,
197 Self: 'async_trait,
198 {
199 Box::pin(async move {
200 self.store
201 .delete_upstream_oauth_credentials(&self.upstream_name, &self.subject)
202 .await
203 .map_err(|e| AuthError::InternalError(e.to_string()))
204 })
205 }
206}
207
208pub struct SqliteStateStore {
213 store: SqliteStore,
214 upstream_name: String,
215 subject: String,
216}
217
218impl SqliteStateStore {
219 pub fn new(
220 store: SqliteStore,
221 upstream_name: impl Into<String>,
222 subject: impl Into<String>,
223 ) -> Self {
224 Self {
225 store,
226 upstream_name: upstream_name.into(),
227 subject: subject.into(),
228 }
229 }
230}
231
232const STATE_TTL_SECS: i64 = 300;
234
235impl StateStore for SqliteStateStore {
236 fn save<'life0, 'life1, 'async_trait>(
237 &'life0 self,
238 csrf_token: &'life1 str,
239 state: StoredAuthorizationState,
240 ) -> Pin<Box<dyn Future<Output = Result<(), AuthError>> + Send + 'async_trait>>
241 where
242 'life0: 'async_trait,
243 'life1: 'async_trait,
244 Self: 'async_trait,
245 {
246 Box::pin(async move {
247 let now = now_unix();
248 let requested_scopes_json =
249 serde_json::to_string(&state.requested_scopes).map_err(|e| {
250 AuthError::InternalError(format!("serialize requested scopes: {e}"))
251 })?;
252 let row = UpstreamOauthStateRow {
253 upstream_name: self.upstream_name.clone(),
254 subject: self.subject.clone(),
255 csrf_token: csrf_token.to_string(),
256 pkce_verifier: state.pkce_verifier,
257 expected_issuer: state.expected_issuer,
258 require_issuer: state.require_issuer,
259 requested_scopes_json,
260 created_at: now,
261 expires_at: now + STATE_TTL_SECS,
262 };
263 self.store
264 .save_upstream_oauth_state(row)
265 .await
266 .map_err(|e| AuthError::InternalError(e.to_string()))
267 })
268 }
269
270 fn load<'life0, 'life1, 'async_trait>(
271 &'life0 self,
272 csrf_token: &'life1 str,
273 ) -> Pin<
274 Box<
275 dyn Future<Output = Result<Option<StoredAuthorizationState>, AuthError>>
276 + Send
277 + 'async_trait,
278 >,
279 >
280 where
281 'life0: 'async_trait,
282 'life1: 'async_trait,
283 Self: 'async_trait,
284 {
285 Box::pin(async move {
286 let now = now_unix();
287 let row = self
288 .store
289 .take_upstream_oauth_state(&self.upstream_name, &self.subject, csrf_token, now)
290 .await
291 .map_err(|e| AuthError::InternalError(e.to_string()))?;
292
293 row.map(|r| {
294 let requested_scopes =
295 serde_json::from_str(&r.requested_scopes_json).map_err(|e| {
296 AuthError::InternalError(format!("deserialize requested scopes: {e}"))
297 })?;
298 let mut state = StoredAuthorizationState::new_with_expected_issuer(
299 &PkceCodeVerifier::new(r.pkce_verifier),
300 &CsrfToken::new(r.csrf_token),
301 r.expected_issuer,
302 r.require_issuer,
303 );
304 state.requested_scopes = requested_scopes;
305 Ok(state)
306 })
307 .transpose()
308 })
309 }
310
311 fn delete<'life0, 'life1, 'async_trait>(
312 &'life0 self,
313 _csrf_token: &'life1 str,
314 ) -> Pin<Box<dyn Future<Output = Result<(), AuthError>> + Send + 'async_trait>>
315 where
316 'life0: 'async_trait,
317 'life1: 'async_trait,
318 Self: 'async_trait,
319 {
320 Box::pin(async move { Ok(()) })
323 }
324}
325
326#[cfg(test)]
335mod tests {
336 use base64::Engine as _;
337 use oauth2::{CsrfToken, PkceCodeVerifier};
338 use rmcp_client::transport::auth::{
339 CredentialStore, StateStore, StoredAuthorizationState, StoredCredentials,
340 };
341
342 use super::{SqliteCredentialStore, SqliteStateStore};
343 use crate::upstream::encryption::load_key;
344
345 async fn temp_store() -> crate::sqlite::SqliteStore {
347 let path = tempfile::tempdir()
348 .expect("tempdir")
349 .keep()
350 .join("upstream_oauth_test.db");
351 crate::sqlite::SqliteStore::open(path)
352 .await
353 .expect("open test store")
354 }
355
356 fn make_state_store(
357 store: crate::sqlite::SqliteStore,
358 upstream: &str,
359 subject: &str,
360 ) -> SqliteStateStore {
361 SqliteStateStore::new(store, upstream, subject)
362 }
363
364 fn sample_stored_state(csrf: &str) -> StoredAuthorizationState {
365 StoredAuthorizationState::new(
366 &PkceCodeVerifier::new("verifier-value".to_string()),
367 &CsrfToken::new(csrf.to_string()),
368 )
369 }
370
371 #[tokio::test]
373 async fn unknown_state_returns_none() {
374 let store = make_state_store(temp_store().await, "acme", "alice");
375 let result = store
376 .load("nonexistent-csrf")
377 .await
378 .expect("load should not error");
379 assert!(result.is_none(), "unknown state token must return None");
380 }
381
382 #[tokio::test]
388 async fn replayed_state_is_rejected() {
389 let sqlite = temp_store().await;
390 let store = make_state_store(sqlite, "acme", "alice");
391 let csrf = "csrf-replay-test";
392
393 store
395 .save(csrf, sample_stored_state(csrf))
396 .await
397 .expect("save should succeed");
398
399 let first = store.load(csrf).await.expect("first load should not error");
401 assert!(first.is_some(), "first load must return the stored state");
402
403 let second = store
405 .load(csrf)
406 .await
407 .expect("second load should not error");
408 assert!(
409 second.is_none(),
410 "replayed (already-consumed) state token must return None"
411 );
412 }
413
414 #[tokio::test]
421 async fn state_round_trip_preserves_rfc9207_issuer_and_requested_scopes() {
422 let sqlite = temp_store().await;
423 let store = make_state_store(sqlite, "acme", "alice");
424 let csrf = "csrf-issuer-round-trip";
425 let mut state = StoredAuthorizationState::new_with_expected_issuer(
426 &PkceCodeVerifier::new("verifier-value".to_string()),
427 &CsrfToken::new(csrf.to_string()),
428 Some("https://issuer.example".to_string()),
429 true,
430 );
431 state.requested_scopes = vec!["mcp.read".to_string(), "mcp.write".to_string()];
432
433 store.save(csrf, state).await.expect("save state");
434 let restored = store
435 .load(csrf)
436 .await
437 .expect("load state")
438 .expect("state exists");
439
440 assert_eq!(
441 restored.expected_issuer.as_deref(),
442 Some("https://issuer.example")
443 );
444 assert!(restored.require_issuer);
445 assert_eq!(restored.requested_scopes, ["mcp.read", "mcp.write"]);
446 }
447
448 fn test_encryption_key() -> crate::upstream::encryption::EncryptionKey {
449 load_key(&base64::engine::general_purpose::STANDARD.encode([7u8; 32])).expect("test key")
450 }
451
452 #[tokio::test]
453 async fn credential_store_rejects_and_deletes_credentials_from_another_issuer() {
454 let sqlite = temp_store().await;
455 let key = test_encryption_key();
456 let issuer_a = SqliteCredentialStore::new(
457 sqlite.clone(),
458 key.clone(),
459 "acme",
460 "alice",
461 "https://issuer-a.example",
462 );
463 issuer_a
464 .save(
465 StoredCredentials::new(
466 "client-a".to_string(),
467 None,
468 vec!["mcp".to_string()],
469 Some(1_700_000_000),
470 )
471 .with_issuer(Some("https://issuer-a.example".to_string())),
472 )
473 .await
474 .expect("save credentials");
475
476 let issuer_b = SqliteCredentialStore::new(
477 sqlite.clone(),
478 key,
479 "acme",
480 "alice",
481 "https://issuer-b.example",
482 );
483 assert!(
484 issuer_b
485 .load()
486 .await
487 .expect("load mismatched credentials")
488 .is_none(),
489 "credentials minted by another issuer must never be reused"
490 );
491 assert!(
492 sqlite
493 .find_upstream_oauth_credentials("acme", "alice")
494 .await
495 .expect("query credentials")
496 .is_none(),
497 "the stale issuer-bound row must be removed"
498 );
499 }
500
501 #[tokio::test]
502 async fn credential_store_rejects_legacy_rows_without_an_issuer() {
503 let sqlite = temp_store().await;
504 sqlite
505 .upsert_upstream_oauth_credentials(crate::types::UpstreamOauthCredentialRow {
506 upstream_name: "acme".to_string(),
507 subject: "alice".to_string(),
508 issuer: String::new(),
509 client_id: "legacy-client".to_string(),
510 granted_scopes_json: "[]".to_string(),
511 token_blob: vec![1, 2, 3],
512 token_blob_nonce: vec![0; 12],
513 token_received_at: 0,
514 access_token_expires_at: 0,
515 refresh_token_present: false,
516 })
517 .await
518 .expect("insert legacy row");
519 let store = SqliteCredentialStore::new(
520 sqlite.clone(),
521 test_encryption_key(),
522 "acme",
523 "alice",
524 "https://issuer.example",
525 );
526
527 assert!(store.load().await.expect("load legacy row").is_none());
528 assert!(
529 sqlite
530 .find_upstream_oauth_credentials("acme", "alice")
531 .await
532 .expect("query legacy row")
533 .is_none()
534 );
535 }
536
537 #[tokio::test]
538 async fn expired_state_is_rejected() {
539 use crate::types::UpstreamOauthStateRow;
540
541 let sqlite = temp_store().await;
542
543 let now = std::time::SystemTime::now()
545 .duration_since(std::time::UNIX_EPOCH)
546 .unwrap_or_default()
547 .as_secs() as i64;
548 let row = UpstreamOauthStateRow {
549 upstream_name: "acme".to_string(),
550 subject: "alice".to_string(),
551 csrf_token: "csrf-expired".to_string(),
552 pkce_verifier: "verifier".to_string(),
553 expected_issuer: Some("https://issuer.example".to_string()),
554 require_issuer: true,
555 requested_scopes_json: "[]".to_string(),
556 created_at: now - 400,
557 expires_at: now - 1, };
559 sqlite
560 .save_upstream_oauth_state(row)
561 .await
562 .expect("save expired row");
563
564 let store = make_state_store(sqlite, "acme", "alice");
566 let result = store
567 .load("csrf-expired")
568 .await
569 .expect("load should not error on expired state");
570 assert!(
571 result.is_none(),
572 "expired state token must return None, not the stored state"
573 );
574 }
575}