pas-external 0.12.0

Ppoppo Accounts System (PAS) external SDK — OAuth2 PKCE, JWT verification port, Axum middleware, session liveness
Documentation
//! Test-support helpers for consumer integration tests.
//!
//! [`FakePasServer`] is a wiremock-wrapped fake PAS Authorization Server.
//! It serves the OIDC discovery document, a JWKS containing the public
//! half of an in-process ed25519 keypair, and the token endpoint —
//! exactly enough for [`super::oidc::RelyingParty::new`] to bootstrap
//! against and for [`super::oidc::RelyingParty::start`] /
//! [`super::oidc::RelyingParty::complete`] /
//! [`super::oidc::RelyingParty::refresh`] to exercise their HTTP code
//! paths without requiring a real PAS deployment.
//!
//! Phase 11.Y replaced the 0.7.x
//! `RelyingParty::for_test_with_parts` test escape hatch with this
//! pattern. Production and test go through the *same* public interface
//! (`RelyingParty::new(config, store)`) — only the network destination
//! differs.
//!
//! # Example
//!
//! ```ignore
//! use std::sync::Arc;
//! use pas_external::oidc::{Config, InMemoryStateStore, RelyingParty, StateStore};
//! use pas_external::test_support::FakePasServer;
//! use ppoppo_token::id_token::scopes::Email;
//!
//! # async fn test_main() {
//! let server = FakePasServer::start().await;
//! let store: Arc<dyn StateStore> = Arc::new(InMemoryStateStore::new());
//!
//! let config = Config::new(
//!     "rp-client-id",
//!     "https://app.example.com/callback".parse().unwrap(),
//!     server.issuer_url(),
//! );
//!
//! // Real boot path — same as production, just against the fake server.
//! let rp = RelyingParty::<Email>::new(config, store).await.unwrap();
//! # }
//! ```

use std::sync::Arc;

use base64::Engine as _;
use ppoppo_token::id_token::{IssueConfig, IssueRequest, ScopeSet};
use ppoppo_token::{Jwks, SigningKey};
use serde::Serialize;
use serde_json::json;
use url::Url;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

// Test-only re-exports of `pub(crate)` SDK internals.
//
// **Scope**: SDK boundary tests (in `tests/*.rs`) and downstream consumer
// integration tests that need to fabricate the OAuth wire DTO or
// instantiate the PAS id_token verifier directly.
//
// **Status**: Phase 11.Y kept these accessible behind the
// `test-support` feature to preserve the existing M-row coverage in
// `tests/id_token_verifier_boundary.rs` + `tests/liveness_boundary.rs`
// without rewriting them in this slice. **11.Z migration target**:
// migrate those tests onto `FakePasServer` + `RelyingParty::complete`
// so the verifier's pub(crate) lockdown is enforced even for SDK-
// internal tests; then drop these re-exports.
//
// Production consumers do **not** use these — the canonical surface is
// `oidc::RelyingParty<S>` + `oidc::RefreshOutcome` + `oidc::Completion<S>`.

/// Re-export of the OAuth2 token-endpoint wire DTO (RFC 6749 §5.1 + OIDC Core §3.1.3.3).
pub use crate::oauth::TokenResponse;

/// Re-export of the production id_token verifier adapter.
pub use crate::oidc::verifier::PasIdTokenVerifier;

// SPKI DER (b64) for the public key matching `SigningKey::test_pair`'s
// private PEM. Last 32 bytes of the decoded DER are the raw Ed25519
// public key. The same constant is duplicated in chat-auth's
// `jwt_cutover_integration.rs` test fixture; ppoppo-token does not
// expose the pair as a wire-shape `Jwks` directly today, so we derive
// here. (See ppoppo-token `signing_key.rs::test_pair` for the matching
// PEM material.)
const TEST_PUBLIC_KEY_SPKI_B64: &str =
    "MCowBQYDK2VwAyEAh//e6j3It3xhjghg8Kpn2pM0jMCH/cvemGu4vv7D1Q4=";

/// Wiremock-wrapped fake PAS Authorization Server.
///
/// [`Self::start`] constructs the server, generates an ed25519
/// keypair, and mounts the discovery + JWKS routes. The returned
/// [`Self::issuer_url`] is the value to pass into `oidc::Config::new`'s
/// `issuer` parameter so that
/// [`crate::oidc::RelyingParty::new`]'s discovery + JWKS fetches
/// resolve here.
///
/// Token-endpoint expectations are configured per-test via
/// [`Self::expect_token_exchange`] and [`Self::reject_next_token_exchange`].
pub struct FakePasServer {
    server: MockServer,
    signing_key: Arc<SigningKey>,
    issuer: Url,
}

impl FakePasServer {
    /// Start a fresh fake PAS server. Mounts the discovery + JWKS
    /// routes immediately; the token endpoint stays unmounted until a
    /// test calls [`Self::expect_token_exchange`] or
    /// [`Self::reject_next_token_exchange`].
    pub async fn start() -> Self {
        let server: MockServer = MockServer::start().await;
        // wiremock's `uri()` is e.g. "http://127.0.0.1:54321" with NO
        // trailing slash; OIDC issuer URLs canonically lack the
        // trailing slash too (RFC 8414 §2). Parsing as `Url` adds it
        // back internally, but the wire-side issuer claim emitted by
        // sign_id_token uses the bare-uri form for consistency with
        // production PAS.
        let issuer: Url = format!("{}/", server.uri())
            .parse()
            .expect("wiremock URL parses as Url");

        let (signing_key, _internal_keyset) = SigningKey::test_pair();
        // Reconstruct the wire-shape Jwks from the SPKI DER constant
        // matching `SigningKey::test_pair`'s public PEM. The internal
        // `KeySet` from `test_pair` is for verify-side wiring (Decoding
        // keys); the discovery JWKS endpoint serves the public-key
        // bytes in JWK shape, which `Jwks::from_ed25519_keys` builds
        // directly from raw 32-byte pubkeys.
        let spki = base64::engine::general_purpose::STANDARD
            .decode(TEST_PUBLIC_KEY_SPKI_B64)
            .expect("test SPKI base64 decodes");
        let pk_bytes: [u8; 32] = spki[12..]
            .try_into()
            .expect("SPKI carries 32-byte raw pubkey at offset 12");
        let kid = signing_key.kid().to_owned();
        let jwks: Jwks = Jwks::from_ed25519_keys(&[(kid.as_str(), &pk_bytes)]);

        Mock::given(method("GET"))
            .and(path("/.well-known/openid-configuration"))
            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
                "issuer": issuer.as_str().trim_end_matches('/'),
                "authorization_endpoint": format!("{issuer}oauth/authorize"),
                "token_endpoint": format!("{issuer}oauth/token"),
                "jwks_uri": format!("{issuer}.well-known/jwks.json"),
                "response_types_supported": ["code"],
                "subject_types_supported": ["public"],
                "id_token_signing_alg_values_supported": ["EdDSA"],
            })))
            .mount(&server)
            .await;

        Mock::given(method("GET"))
            .and(path("/.well-known/jwks.json"))
            .respond_with(ResponseTemplate::new(200).set_body_json(&jwks))
            .mount(&server)
            .await;

        Self {
            server,
            signing_key: Arc::new(signing_key),
            issuer,
        }
    }

    /// Issuer URL — pass to `oidc::Config::new(client_id, redirect_uri, issuer)`.
    #[must_use]
    pub fn issuer_url(&self) -> Url {
        self.issuer.clone()
    }

    /// Token endpoint URL (`<issuer>/oauth/token`).
    #[must_use]
    pub fn token_url(&self) -> Url {
        let mut u = self.issuer.clone();
        u.set_path("/oauth/token");
        u
    }

    /// JWKS URL (`<issuer>/.well-known/jwks.json`).
    #[must_use]
    pub fn jwks_url(&self) -> Url {
        let mut u = self.issuer.clone();
        u.set_path("/.well-known/jwks.json");
        u
    }

    /// Sign an id_token with the fake server's signing key.
    ///
    /// The resulting JWT is verifiable by any
    /// [`crate::oidc::RelyingParty::new`]-bootstrapped RP that
    /// fetched JWKS from this server.
    pub fn sign_id_token<S: ScopeSet>(
        &self,
        request: &IssueRequest<S>,
        config: &IssueConfig,
    ) -> Result<String, ppoppo_token::id_token::IssueError> {
        let now = time::OffsetDateTime::now_utc().unix_timestamp();
        ppoppo_token::id_token::issue(request, config, &self.signing_key, now)
    }

    /// Mount a one-shot mock for `POST /oauth/token` returning `body`
    /// as 200 JSON. Multiple calls stack; each is consumed once in the
    /// order they were configured.
    pub async fn expect_token_exchange(&self, body: TokenExchangeBody) {
        Mock::given(method("POST"))
            .and(path("/oauth/token"))
            .respond_with(ResponseTemplate::new(200).set_body_json(&body))
            .expect(1)
            .mount(&self.server)
            .await;
    }

    /// Mount a one-shot mock for `POST /oauth/token` returning the
    /// given HTTP status + plaintext body (typical for testing 4xx
    /// rejection or 5xx server-error paths).
    pub async fn reject_next_token_exchange(&self, status: u16, body: impl Into<String>) {
        let body_owned = body.into();
        Mock::given(method("POST"))
            .and(path("/oauth/token"))
            .respond_with(ResponseTemplate::new(status).set_body_string(body_owned))
            .expect(1)
            .mount(&self.server)
            .await;
    }
}

/// JSON body for [`FakePasServer::expect_token_exchange`].
///
/// Mirrors the OAuth 2.0 token-endpoint response shape (RFC 6749 §5.1
/// + OIDC Core §3.1.3.3). Use [`Self::bearer`] for the minimal-success
/// shape and chain `with_*` builders to add optional fields.
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct TokenExchangeBody {
    pub access_token: String,
    pub token_type: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub expires_in: Option<u64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub refresh_token: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub id_token: Option<String>,
}

impl TokenExchangeBody {
    /// Minimal Bearer-typed token response with `expires_in = 3600`.
    #[must_use]
    pub fn bearer(access_token: impl Into<String>) -> Self {
        Self {
            access_token: access_token.into(),
            token_type: "Bearer".to_owned(),
            expires_in: Some(3600),
            refresh_token: None,
            id_token: None,
        }
    }

    /// Set the `refresh_token` field (PAS rotated the credential).
    #[must_use]
    pub fn with_refresh_token(mut self, rt: impl Into<String>) -> Self {
        self.refresh_token = Some(rt.into());
        self
    }

    /// Set the `id_token` field (OIDC scope flows). Use
    /// [`FakePasServer::sign_id_token`] to produce the value.
    #[must_use]
    pub fn with_id_token(mut self, id_token: impl Into<String>) -> Self {
        self.id_token = Some(id_token.into());
        self
    }

    /// Override the `expires_in` hint.
    #[must_use]
    pub fn with_expires_in(mut self, secs: u64) -> Self {
        self.expires_in = Some(secs);
        self
    }
}