Skip to main content

soma_auth/upstream/
store.rs

1//! SQLite-backed rmcp `CredentialStore` and `StateStore` adapters.
2//!
3//! Both adapters are per-`(upstream_name, subject)` — construct a fresh pair for each
4//! upstream × subject combination.  The underlying `SqliteStore` is `Clone` so the
5//! shared connection pool is cheap to duplicate.
6//!
7//! # `StateStore::load` is a consuming take
8//!
9//! `StateStore::load` calls `take_upstream_oauth_state` (DELETE … RETURNING) rather than
10//! a plain SELECT.  This is intentional: consuming state on first read closes the replay
11//! window.  `StateStore::delete` is therefore a no-op — the row is already gone after
12//! a successful `load`.
13//!
14//! # Lifetime pattern
15//!
16//! `#[async_trait]` expands `async fn foo(&self)` to a two-lifetime form:
17//! `fn foo<'life0, 'async_trait>(&'life0 self) where 'life0: 'async_trait, Self: 'async_trait`.
18//! All method impls below match that exact pattern without importing the `async-trait` crate.
19
20use 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, client_id: &str) -> Vec<u8> {
34    format!("upstream={upstream_name}\0subject={subject}\0client_id={client_id}").into_bytes()
35}
36
37fn now_unix() -> i64 {
38    std::time::SystemTime::now()
39        .duration_since(std::time::UNIX_EPOCH)
40        .unwrap_or_default()
41        .as_secs() as i64
42}
43
44/// Per-`(upstream_name, subject)` credential store backed by SQLite.
45///
46/// Tokens are encrypted at rest with ChaCha20-Poly1305.  Decryption failure
47/// surfaces as `AuthError::AuthorizationRequired` so callers re-initiate the
48/// authorization flow rather than hard-failing.
49pub struct SqliteCredentialStore {
50    store: SqliteStore,
51    key: EncryptionKey,
52    upstream_name: String,
53    subject: String,
54}
55
56impl SqliteCredentialStore {
57    pub fn new(
58        store: SqliteStore,
59        key: EncryptionKey,
60        upstream_name: impl Into<String>,
61        subject: impl Into<String>,
62    ) -> Self {
63        Self {
64            store,
65            key,
66            upstream_name: upstream_name.into(),
67            subject: subject.into(),
68        }
69    }
70}
71
72impl CredentialStore for SqliteCredentialStore {
73    fn load<'life0, 'async_trait>(
74        &'life0 self,
75    ) -> Pin<
76        Box<
77            dyn Future<Output = Result<Option<StoredCredentials>, AuthError>> + Send + 'async_trait,
78        >,
79    >
80    where
81        'life0: 'async_trait,
82        Self: 'async_trait,
83    {
84        Box::pin(async move {
85            let row = self
86                .store
87                .find_upstream_oauth_credentials(&self.upstream_name, &self.subject)
88                .await
89                .map_err(|e| AuthError::InternalError(e.to_string()))?;
90
91            let Some(row) = row else {
92                return Ok(None);
93            };
94
95            let aad = credential_aad(&row.upstream_name, &row.subject, &row.client_id);
96            let plaintext =
97                encryption::open_with_aad(&self.key, &row.token_blob, &row.token_blob_nonce, &aad)
98                    .map_err(|_| AuthError::AuthorizationRequired)?;
99
100            let creds: StoredCredentials = serde_json::from_slice(&plaintext)
101                .map_err(|e| AuthError::InternalError(format!("deserialize credentials: {e}")))?;
102
103            Ok(Some(creds))
104        })
105    }
106
107    fn save<'life0, 'async_trait>(
108        &'life0 self,
109        credentials: StoredCredentials,
110    ) -> Pin<Box<dyn Future<Output = Result<(), AuthError>> + Send + 'async_trait>>
111    where
112        'life0: 'async_trait,
113        Self: 'async_trait,
114    {
115        Box::pin(async move {
116            let token_received_at = credentials
117                .token_received_at
118                .map(|t| t as i64)
119                .unwrap_or_else(now_unix);
120
121            let (access_token_expires_at, refresh_token_present) =
122                if let Some(ref token) = credentials.token_response {
123                    let expires_in = token.expires_in().map(|d| d.as_secs()).unwrap_or(3600) as i64;
124                    (
125                        token_received_at + expires_in,
126                        token.refresh_token().is_some(),
127                    )
128                } else {
129                    (0, false)
130                };
131
132            let granted_scopes_json = serde_json::to_string(&credentials.granted_scopes)
133                .map_err(|e| AuthError::InternalError(format!("serialize scopes: {e}")))?;
134
135            let plaintext = serde_json::to_vec(&credentials)
136                .map_err(|e| AuthError::InternalError(format!("serialize credentials: {e}")))?;
137
138            let aad = credential_aad(&self.upstream_name, &self.subject, &credentials.client_id);
139            let (token_blob, token_blob_nonce) =
140                encryption::seal_with_aad(&self.key, &plaintext, &aad)
141                    .map_err(|e| AuthError::InternalError(format!("encrypt credentials: {e}")))?;
142
143            let row = UpstreamOauthCredentialRow {
144                upstream_name: self.upstream_name.clone(),
145                subject: self.subject.clone(),
146                client_id: credentials.client_id.clone(),
147                granted_scopes_json,
148                token_blob,
149                token_blob_nonce,
150                token_received_at,
151                access_token_expires_at,
152                refresh_token_present,
153            };
154
155            self.store
156                .upsert_upstream_oauth_credentials(row)
157                .await
158                .map_err(|e| AuthError::InternalError(e.to_string()))
159        })
160    }
161
162    fn clear<'life0, 'async_trait>(
163        &'life0 self,
164    ) -> Pin<Box<dyn Future<Output = Result<(), AuthError>> + Send + 'async_trait>>
165    where
166        'life0: 'async_trait,
167        Self: 'async_trait,
168    {
169        Box::pin(async move {
170            self.store
171                .delete_upstream_oauth_credentials(&self.upstream_name, &self.subject)
172                .await
173                .map_err(|e| AuthError::InternalError(e.to_string()))
174        })
175    }
176}
177
178/// Per-`(upstream_name, subject)` state store backed by SQLite.
179///
180/// The `load` method uses `take_upstream_oauth_state` (atomic DELETE … RETURNING)
181/// rather than a SELECT, consuming the row on first read.  `delete` is a no-op.
182pub struct SqliteStateStore {
183    store: SqliteStore,
184    upstream_name: String,
185    subject: String,
186}
187
188impl SqliteStateStore {
189    pub fn new(
190        store: SqliteStore,
191        upstream_name: impl Into<String>,
192        subject: impl Into<String>,
193    ) -> Self {
194        Self {
195            store,
196            upstream_name: upstream_name.into(),
197            subject: subject.into(),
198        }
199    }
200}
201
202/// TTL for pending authorization state (5 minutes).
203const STATE_TTL_SECS: i64 = 300;
204
205impl StateStore for SqliteStateStore {
206    fn save<'life0, 'life1, 'async_trait>(
207        &'life0 self,
208        csrf_token: &'life1 str,
209        state: StoredAuthorizationState,
210    ) -> Pin<Box<dyn Future<Output = Result<(), AuthError>> + Send + 'async_trait>>
211    where
212        'life0: 'async_trait,
213        'life1: 'async_trait,
214        Self: 'async_trait,
215    {
216        Box::pin(async move {
217            let now = now_unix();
218            let row = UpstreamOauthStateRow {
219                upstream_name: self.upstream_name.clone(),
220                subject: self.subject.clone(),
221                csrf_token: csrf_token.to_string(),
222                pkce_verifier: state.pkce_verifier,
223                created_at: now,
224                expires_at: now + STATE_TTL_SECS,
225            };
226            self.store
227                .save_upstream_oauth_state(row)
228                .await
229                .map_err(|e| AuthError::InternalError(e.to_string()))
230        })
231    }
232
233    fn load<'life0, 'life1, 'async_trait>(
234        &'life0 self,
235        csrf_token: &'life1 str,
236    ) -> Pin<
237        Box<
238            dyn Future<Output = Result<Option<StoredAuthorizationState>, AuthError>>
239                + Send
240                + 'async_trait,
241        >,
242    >
243    where
244        'life0: 'async_trait,
245        'life1: 'async_trait,
246        Self: 'async_trait,
247    {
248        Box::pin(async move {
249            let now = now_unix();
250            let row = self
251                .store
252                .take_upstream_oauth_state(&self.upstream_name, &self.subject, csrf_token, now)
253                .await
254                .map_err(|e| AuthError::InternalError(e.to_string()))?;
255
256            Ok(row.map(|r| {
257                StoredAuthorizationState::new(
258                    &PkceCodeVerifier::new(r.pkce_verifier),
259                    &CsrfToken::new(r.csrf_token),
260                )
261            }))
262        })
263    }
264
265    fn delete<'life0, 'life1, 'async_trait>(
266        &'life0 self,
267        _csrf_token: &'life1 str,
268    ) -> Pin<Box<dyn Future<Output = Result<(), AuthError>> + Send + 'async_trait>>
269    where
270        'life0: 'async_trait,
271        'life1: 'async_trait,
272        Self: 'async_trait,
273    {
274        // `load` already performs an atomic DELETE … RETURNING; a separate
275        // delete call would be a double-delete with no effect.
276        Box::pin(async move { Ok(()) })
277    }
278}
279
280// ── T10: OAuth callback/state error-path tests ────────────────────────────────
281//
282// Verify that the `SqliteStateStore` adapter correctly rejects unknown,
283// replayed (already-consumed), and expired state tokens.  These paths were
284// previously un-asserted at the adapter layer; the underlying `SqliteStore`
285// primitive is tested in `soma-auth/src/sqlite.rs` but those tests exercise the
286// raw SQL, not the rmcp `StateStore` adapter behaviour.
287
288#[cfg(test)]
289mod tests {
290    use oauth2::{CsrfToken, PkceCodeVerifier};
291    use rmcp_client::transport::auth::{StateStore, StoredAuthorizationState};
292
293    use super::SqliteStateStore;
294
295    /// Open a disposable in-memory SQLite store for testing.
296    async fn temp_store() -> crate::sqlite::SqliteStore {
297        let path = tempfile::tempdir()
298            .expect("tempdir")
299            .keep()
300            .join("upstream_oauth_test.db");
301        crate::sqlite::SqliteStore::open(path)
302            .await
303            .expect("open test store")
304    }
305
306    fn make_state_store(
307        store: crate::sqlite::SqliteStore,
308        upstream: &str,
309        subject: &str,
310    ) -> SqliteStateStore {
311        SqliteStateStore::new(store, upstream, subject)
312    }
313
314    fn sample_stored_state(csrf: &str) -> StoredAuthorizationState {
315        StoredAuthorizationState::new(
316            &PkceCodeVerifier::new("verifier-value".to_string()),
317            &CsrfToken::new(csrf.to_string()),
318        )
319    }
320
321    /// Loading a state token that was never saved returns `None`.
322    #[tokio::test]
323    async fn unknown_state_returns_none() {
324        let store = make_state_store(temp_store().await, "acme", "alice");
325        let result = store
326            .load("nonexistent-csrf")
327            .await
328            .expect("load should not error");
329        assert!(result.is_none(), "unknown state token must return None");
330    }
331
332    /// Loading a state token a second time returns `None` (replay prevention).
333    ///
334    /// `SqliteStateStore::load` uses an atomic DELETE … RETURNING so the first
335    /// successful load consumes the row.  A subsequent call for the same token
336    /// must return `None` rather than re-authorizing the flow.
337    #[tokio::test]
338    async fn replayed_state_is_rejected() {
339        let sqlite = temp_store().await;
340        let store = make_state_store(sqlite, "acme", "alice");
341        let csrf = "csrf-replay-test";
342
343        // Save state once.
344        store
345            .save(csrf, sample_stored_state(csrf))
346            .await
347            .expect("save should succeed");
348
349        // First load consumes the row.
350        let first = store.load(csrf).await.expect("first load should not error");
351        assert!(first.is_some(), "first load must return the stored state");
352
353        // Second load must return None — the row no longer exists.
354        let second = store
355            .load(csrf)
356            .await
357            .expect("second load should not error");
358        assert!(
359            second.is_none(),
360            "replayed (already-consumed) state token must return None"
361        );
362    }
363
364    /// Loading a state token whose TTL has expired returns `None`.
365    ///
366    /// The underlying `take_upstream_oauth_state` query filters by `expires_at`,
367    /// so an expired row is treated the same as a missing row.  We simulate this
368    /// by writing a row with `expires_at = now - 1` directly via the raw store.
369    #[tokio::test]
370    async fn expired_state_is_rejected() {
371        use crate::types::UpstreamOauthStateRow;
372
373        let sqlite = temp_store().await;
374
375        // Insert an already-expired row directly (bypassing the adapter's TTL).
376        let now = std::time::SystemTime::now()
377            .duration_since(std::time::UNIX_EPOCH)
378            .unwrap_or_default()
379            .as_secs() as i64;
380        let row = UpstreamOauthStateRow {
381            upstream_name: "acme".to_string(),
382            subject: "alice".to_string(),
383            csrf_token: "csrf-expired".to_string(),
384            pkce_verifier: "verifier".to_string(),
385            created_at: now - 400,
386            expires_at: now - 1, // already expired
387        };
388        sqlite
389            .save_upstream_oauth_state(row)
390            .await
391            .expect("save expired row");
392
393        // The adapter must refuse to return an expired state.
394        let store = make_state_store(sqlite, "acme", "alice");
395        let result = store
396            .load("csrf-expired")
397            .await
398            .expect("load should not error on expired state");
399        assert!(
400            result.is_none(),
401            "expired state token must return None, not the stored state"
402        );
403    }
404}