Skip to main content

pas_external/oidc/
memory.rs

1//! In-memory adapters for tests + boundary fixtures.
2//!
3//! Gated behind `cfg(any(test, feature = "test-support"))` so production
4//! builds never link the test machinery. Two adapters live here:
5//!
6//! - [`MemoryIdTokenVerifier<S>`] — in-memory [`IdTokenVerifier<S>`].
7//!   Mirrors [`crate::MemoryBearerVerifier`] structurally —
8//!   same in-memory pattern, different port.
9//! - [`InMemoryStateStore`] — in-memory [`super::StateStore`].
10//!   Atomic single-use guaranteed via `tokio::sync::Mutex<HashMap>`
11//!   held across `put` and `take`. Phase 11.A.
12//!
13//! Two ways to fail a verification:
14//!
15//! - **Lookup miss**: the token string isn't in the `assertions` map.
16//!   Surfaces as [`IdVerifyError::SignatureInvalid`] (the boundary
17//!   semantic of "this token doesn't verify against our keyset" maps
18//!   identically to "this token is unknown to the test fixture").
19//! - **Default failure**: any token returns the configured error.
20//!   Used to simulate keyset outages, expired tokens, M66 nonce
21//!   mismatches, etc. without pre-registering every variant.
22//!
23//! **Nonce semantics**: the test verifier ignores the per-call nonce
24//! by default. Test fixtures control success/failure via the
25//! `assertions` map and `default_failure`; callers that want to
26//! simulate a nonce mismatch insert
27//! `IdVerifyError::NonceMismatch` as the `default_failure`.
28
29use std::collections::HashMap;
30use std::marker::PhantomData;
31
32use async_trait::async_trait;
33use ppoppo_token::id_token::Nonce;
34
35use super::port::{IdAssertion, IdTokenVerifier, IdVerifyError, ScopePiiReader};
36use crate::VerifyConfig;
37
38/// In-memory verifier — pre-registered token → [`IdAssertion<S>`] map.
39pub struct MemoryIdTokenVerifier<S: ScopePiiReader> {
40    assertions: HashMap<String, IdAssertion<S>>,
41    default_failure: Option<IdVerifyError>,
42    /// Optional expectations stored for symmetry with
43    /// [`super::PasIdTokenVerifier`]. The in-memory verifier doesn't
44    /// consult them by default — the test fixture controls
45    /// success/failure via the `assertions` map and `default_failure`.
46    /// Callers that want to simulate iss/aud mismatches insert an
47    /// `IssuerInvalid` / `AudienceInvalid` `default_failure`.
48    #[allow(dead_code)]
49    expectations: Option<VerifyConfig>,
50    _scope: PhantomData<S>,
51}
52
53impl<S: ScopePiiReader> Default for MemoryIdTokenVerifier<S> {
54    /// Manual `Default` impl — `#[derive(Default)]` would generate an
55    /// `S: Default` bound, but engine scope markers (`Openid`, `Email`,
56    /// etc.) intentionally do not implement `Default` (they're unit
57    /// witnesses, not constructible state). Manual impl drops the
58    /// extra bound; the struct fields are all `Default` regardless of S.
59    fn default() -> Self {
60        Self {
61            assertions: HashMap::new(),
62            default_failure: None,
63            expectations: None,
64            _scope: PhantomData,
65        }
66    }
67}
68
69impl<S: ScopePiiReader> MemoryIdTokenVerifier<S> {
70    #[must_use]
71    pub fn new() -> Self {
72        Self::default()
73    }
74
75    /// Attach VerifyConfig for symmetry with PasIdTokenVerifier
76    /// construction. The in-memory adapter does not currently consult
77    /// them; it's a hook point for future "simulate iss/aud check"
78    /// semantics.
79    #[must_use]
80    pub fn with_expectations(mut self, expectations: VerifyConfig) -> Self {
81        self.expectations = Some(expectations);
82        self
83    }
84
85    /// Register a successful verification: when `id_token` is
86    /// presented, return `assertion`.
87    pub fn insert(
88        &mut self,
89        id_token: impl Into<String>,
90        assertion: IdAssertion<S>,
91    ) -> &mut Self {
92        self.assertions.insert(id_token.into(), assertion);
93        self
94    }
95
96    /// Configure a global default failure — every id_token (including
97    /// those in the `assertions` map) returns this error. Useful for
98    /// simulating a keyset outage or M66 nonce-mismatch in consumer
99    /// integration tests.
100    pub fn fail_with(&mut self, err: IdVerifyError) -> &mut Self {
101        self.default_failure = Some(err);
102        self
103    }
104}
105
106#[async_trait]
107impl<S: ScopePiiReader> IdTokenVerifier<S> for MemoryIdTokenVerifier<S> {
108    async fn verify(
109        &self,
110        id_token: &str,
111        _expected_nonce: &Nonce,
112    ) -> Result<IdAssertion<S>, IdVerifyError> {
113        if let Some(err) = self.default_failure.clone() {
114            return Err(err);
115        }
116        self.assertions
117            .get(id_token)
118            .cloned()
119            .ok_or(IdVerifyError::SignatureInvalid)
120    }
121}
122
123// ────────────────────────────────────────────────────────────────────────
124// InMemoryStateStore — Phase 11.A
125// ────────────────────────────────────────────────────────────────────────
126//
127// Atomic single-use semantics emulated via `tokio::sync::Mutex<HashMap>`
128// held across `put` and `take`. Production substrates achieve the same
129// invariant via Redis SCRIPT, Postgres `DELETE … RETURNING`, or
130// KVRocks `GETDEL`.
131//
132// TTL is approximated: entries carry an `expires_at` deadline; `take`
133// returns `None` if the entry expired even though the key is still in
134// the map. A real substrate would expire keys server-side; this fake
135// only checks the deadline at read time, which is sufficient because
136// the boundary tests don't rely on background expiry.
137
138#[cfg(feature = "oauth")]
139mod state_store_impl {
140    use std::collections::HashMap;
141    use std::sync::Arc;
142    use std::time::Duration;
143
144    use async_trait::async_trait;
145    use jiff::Timestamp;
146    use ppoppo_clock::ArcClock;
147    use ppoppo_clock::native::WallClock;
148    use tokio::sync::Mutex;
149
150    use crate::oidc::state_store::{
151        PendingAuthRequest, State, StateStore, StateStoreError,
152    };
153
154    struct Entry {
155        pending: PendingAuthRequest,
156        expires_at: Timestamp,
157    }
158
159    /// In-memory atomic single-use state store for boundary tests.
160    ///
161    /// **Atomicity guarantee**: holds a `tokio::sync::Mutex<HashMap>`
162    /// across both `put` and `take`. A second `take` for the same key
163    /// — concurrent or sequential — returns `None`, regardless of
164    /// whether the first `take` happened in the same task or another.
165    /// This is the load-bearing CSRF / state-replay defense the
166    /// boundary tests exercise.
167    ///
168    /// **Optional fault injection**: `with_put_failure` /
169    /// `with_take_failure` queue substrate-level errors for the next N
170    /// calls so tests can drive the
171    /// [`StartError::StateStore`](crate::oidc::StartError::StateStore)
172    /// /
173    /// [`CallbackError::StateStore`](crate::oidc::CallbackError::StateStore)
174    /// branches without standing up a fake substrate.
175    pub struct InMemoryStateStore {
176        inner: Mutex<Inner>,
177        clock: ArcClock,
178    }
179
180    struct Inner {
181        map: HashMap<State, Entry>,
182        put_failures: Vec<StateStoreError>,
183        take_failures: Vec<StateStoreError>,
184    }
185
186    impl Default for InMemoryStateStore {
187        fn default() -> Self {
188            Self {
189                inner: Mutex::new(Inner {
190                    map: HashMap::new(),
191                    put_failures: Vec::new(),
192                    take_failures: Vec::new(),
193                }),
194                clock: Arc::new(WallClock),
195            }
196        }
197    }
198
199    impl InMemoryStateStore {
200        #[must_use]
201        pub fn new() -> Self {
202            Self::default()
203        }
204
205        #[must_use]
206        pub fn with_clock(mut self, clock: ArcClock) -> Self {
207            self.clock = clock;
208            self
209        }
210
211        /// Queue a `put` failure. The next call to `put` returns this
212        /// error; subsequent calls return queued failures in order, then
213        /// fall through to normal behavior.
214        pub async fn with_put_failure(self, err: StateStoreError) -> Self {
215            self.inner.lock().await.put_failures.push(err);
216            self
217        }
218
219        /// Queue a `take` failure (same semantics as `with_put_failure`).
220        pub async fn with_take_failure(self, err: StateStoreError) -> Self {
221            self.inner.lock().await.take_failures.push(err);
222            self
223        }
224    }
225
226    #[async_trait]
227    impl StateStore for InMemoryStateStore {
228        async fn put(
229            &self,
230            state: &State,
231            pending: PendingAuthRequest,
232            ttl: Duration,
233        ) -> Result<(), StateStoreError> {
234            let mut inner = self.inner.lock().await;
235            if let Some(err) = inner.put_failures.pop() {
236                return Err(err);
237            }
238            let expires_at = self.clock.now() + ttl;
239            inner.map.insert(
240                state.clone(),
241                Entry {
242                    pending,
243                    expires_at,
244                },
245            );
246            Ok(())
247        }
248
249        async fn take(
250            &self,
251            state: &State,
252        ) -> Result<Option<PendingAuthRequest>, StateStoreError> {
253            let mut inner = self.inner.lock().await;
254            if let Some(err) = inner.take_failures.pop() {
255                return Err(err);
256            }
257            let Some(entry) = inner.map.remove(state) else {
258                return Ok(None);
259            };
260            if self.clock.now() > entry.expires_at {
261                // Entry expired — substrate would have GC'd it; emulate.
262                return Ok(None);
263            }
264            Ok(Some(entry.pending))
265        }
266    }
267}
268
269#[cfg(feature = "oauth")]
270pub use state_store_impl::InMemoryStateStore;