pas-external 0.12.0

Ppoppo Accounts System (PAS) external SDK — OAuth2 PKCE, JWT verification port, Axum middleware, session liveness
Documentation
//! In-memory adapters for tests + boundary fixtures.
//!
//! Gated behind `cfg(any(test, feature = "test-support"))` so production
//! builds never link the test machinery. Two adapters live here:
//!
//! - [`MemoryIdTokenVerifier<S>`] — in-memory [`IdTokenVerifier<S>`].
//!   Mirrors [`crate::MemoryBearerVerifier`] structurally —
//!   same in-memory pattern, different port.
//! - [`InMemoryStateStore`] — in-memory [`super::StateStore`].
//!   Atomic single-use guaranteed via `tokio::sync::Mutex<HashMap>`
//!   held across `put` and `take`. Phase 11.A.
//!
//! Two ways to fail a verification:
//!
//! - **Lookup miss**: the token string isn't in the `assertions` map.
//!   Surfaces as [`IdVerifyError::SignatureInvalid`] (the boundary
//!   semantic of "this token doesn't verify against our keyset" maps
//!   identically to "this token is unknown to the test fixture").
//! - **Default failure**: any token returns the configured error.
//!   Used to simulate keyset outages, expired tokens, M66 nonce
//!   mismatches, etc. without pre-registering every variant.
//!
//! **Nonce semantics**: the test verifier ignores the per-call nonce
//! by default. Test fixtures control success/failure via the
//! `assertions` map and `default_failure`; callers that want to
//! simulate a nonce mismatch insert
//! `IdVerifyError::NonceMismatch` as the `default_failure`.

use std::collections::HashMap;
use std::marker::PhantomData;

use async_trait::async_trait;
use ppoppo_token::id_token::Nonce;

use super::port::{IdAssertion, IdTokenVerifier, IdVerifyError, ScopePiiReader};
use crate::VerifyConfig;

/// In-memory verifier — pre-registered token → [`IdAssertion<S>`] map.
pub struct MemoryIdTokenVerifier<S: ScopePiiReader> {
    assertions: HashMap<String, IdAssertion<S>>,
    default_failure: Option<IdVerifyError>,
    /// Optional expectations stored for symmetry with
    /// [`super::PasIdTokenVerifier`]. The in-memory verifier doesn't
    /// consult them by default — the test fixture controls
    /// success/failure via the `assertions` map and `default_failure`.
    /// Callers that want to simulate iss/aud mismatches insert an
    /// `IssuerInvalid` / `AudienceInvalid` `default_failure`.
    #[allow(dead_code)]
    expectations: Option<VerifyConfig>,
    _scope: PhantomData<S>,
}

impl<S: ScopePiiReader> Default for MemoryIdTokenVerifier<S> {
    /// Manual `Default` impl — `#[derive(Default)]` would generate an
    /// `S: Default` bound, but engine scope markers (`Openid`, `Email`,
    /// etc.) intentionally do not implement `Default` (they're unit
    /// witnesses, not constructible state). Manual impl drops the
    /// extra bound; the struct fields are all `Default` regardless of S.
    fn default() -> Self {
        Self {
            assertions: HashMap::new(),
            default_failure: None,
            expectations: None,
            _scope: PhantomData,
        }
    }
}

impl<S: ScopePiiReader> MemoryIdTokenVerifier<S> {
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Attach VerifyConfig for symmetry with PasIdTokenVerifier
    /// construction. The in-memory adapter does not currently consult
    /// them; it's a hook point for future "simulate iss/aud check"
    /// semantics.
    #[must_use]
    pub fn with_expectations(mut self, expectations: VerifyConfig) -> Self {
        self.expectations = Some(expectations);
        self
    }

    /// Register a successful verification: when `id_token` is
    /// presented, return `assertion`.
    pub fn insert(
        &mut self,
        id_token: impl Into<String>,
        assertion: IdAssertion<S>,
    ) -> &mut Self {
        self.assertions.insert(id_token.into(), assertion);
        self
    }

    /// Configure a global default failure — every id_token (including
    /// those in the `assertions` map) returns this error. Useful for
    /// simulating a keyset outage or M66 nonce-mismatch in consumer
    /// integration tests.
    pub fn fail_with(&mut self, err: IdVerifyError) -> &mut Self {
        self.default_failure = Some(err);
        self
    }
}

#[async_trait]
impl<S: ScopePiiReader> IdTokenVerifier<S> for MemoryIdTokenVerifier<S> {
    async fn verify(
        &self,
        id_token: &str,
        _expected_nonce: &Nonce,
    ) -> Result<IdAssertion<S>, IdVerifyError> {
        if let Some(err) = self.default_failure.clone() {
            return Err(err);
        }
        self.assertions
            .get(id_token)
            .cloned()
            .ok_or(IdVerifyError::SignatureInvalid)
    }
}

// ────────────────────────────────────────────────────────────────────────
// InMemoryStateStore — Phase 11.A
// ────────────────────────────────────────────────────────────────────────
//
// Atomic single-use semantics emulated via `tokio::sync::Mutex<HashMap>`
// held across `put` and `take`. Production substrates achieve the same
// invariant via Redis SCRIPT, Postgres `DELETE … RETURNING`, or
// KVRocks `GETDEL`.
//
// TTL is approximated: entries carry an `expires_at` deadline; `take`
// returns `None` if the entry expired even though the key is still in
// the map. A real substrate would expire keys server-side; this fake
// only checks the deadline at read time, which is sufficient because
// the boundary tests don't rely on background expiry.

#[cfg(feature = "oauth")]
mod state_store_impl {
    use std::collections::HashMap;
    use std::sync::Arc;
    use std::time::Duration;

    use async_trait::async_trait;
    use ppoppo_clock::ArcClock;
    use ppoppo_clock::native::WallClock;
    use time::OffsetDateTime;
    use tokio::sync::Mutex;

    use crate::oidc::state_store::{
        PendingAuthRequest, State, StateStore, StateStoreError,
    };

    struct Entry {
        pending: PendingAuthRequest,
        expires_at: OffsetDateTime,
    }

    /// In-memory atomic single-use state store for boundary tests.
    ///
    /// **Atomicity guarantee**: holds a `tokio::sync::Mutex<HashMap>`
    /// across both `put` and `take`. A second `take` for the same key
    /// — concurrent or sequential — returns `None`, regardless of
    /// whether the first `take` happened in the same task or another.
    /// This is the load-bearing CSRF / state-replay defense the
    /// boundary tests exercise.
    ///
    /// **Optional fault injection**: `with_put_failure` /
    /// `with_take_failure` queue substrate-level errors for the next N
    /// calls so tests can drive the
    /// [`StartError::StateStore`](crate::oidc::StartError::StateStore)
    /// /
    /// [`CallbackError::StateStore`](crate::oidc::CallbackError::StateStore)
    /// branches without standing up a fake substrate.
    pub struct InMemoryStateStore {
        inner: Mutex<Inner>,
        clock: ArcClock,
    }

    struct Inner {
        map: HashMap<State, Entry>,
        put_failures: Vec<StateStoreError>,
        take_failures: Vec<StateStoreError>,
    }

    impl Default for InMemoryStateStore {
        fn default() -> Self {
            Self {
                inner: Mutex::new(Inner {
                    map: HashMap::new(),
                    put_failures: Vec::new(),
                    take_failures: Vec::new(),
                }),
                clock: Arc::new(WallClock),
            }
        }
    }

    impl InMemoryStateStore {
        #[must_use]
        pub fn new() -> Self {
            Self::default()
        }

        #[must_use]
        pub fn with_clock(mut self, clock: ArcClock) -> Self {
            self.clock = clock;
            self
        }

        /// Queue a `put` failure. The next call to `put` returns this
        /// error; subsequent calls return queued failures in order, then
        /// fall through to normal behavior.
        pub async fn with_put_failure(self, err: StateStoreError) -> Self {
            self.inner.lock().await.put_failures.push(err);
            self
        }

        /// Queue a `take` failure (same semantics as `with_put_failure`).
        pub async fn with_take_failure(self, err: StateStoreError) -> Self {
            self.inner.lock().await.take_failures.push(err);
            self
        }
    }

    #[async_trait]
    impl StateStore for InMemoryStateStore {
        async fn put(
            &self,
            state: &State,
            pending: PendingAuthRequest,
            ttl: Duration,
        ) -> Result<(), StateStoreError> {
            let mut inner = self.inner.lock().await;
            if let Some(err) = inner.put_failures.pop() {
                return Err(err);
            }
            let expires_at = self.clock.now_utc() + ttl;
            inner.map.insert(
                state.clone(),
                Entry {
                    pending,
                    expires_at,
                },
            );
            Ok(())
        }

        async fn take(
            &self,
            state: &State,
        ) -> Result<Option<PendingAuthRequest>, StateStoreError> {
            let mut inner = self.inner.lock().await;
            if let Some(err) = inner.take_failures.pop() {
                return Err(err);
            }
            let Some(entry) = inner.map.remove(state) else {
                return Ok(None);
            };
            if self.clock.now_utc() > entry.expires_at {
                // Entry expired — substrate would have GC'd it; emulate.
                return Ok(None);
            }
            Ok(Some(entry.pending))
        }
    }
}

#[cfg(feature = "oauth")]
pub use state_store_impl::InMemoryStateStore;