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;