ppoppo-sdk-core 0.2.0

Internal shared primitives for the Ppoppo SDK family (pas-external, pas-plims, pcs-external) — verifier port, audit trait, session liveness port, OIDC discovery, perimeter Bearer-auth Layer kit, identity types. Not a stable public API; do not depend on this crate directly. Consume the SDK crates that re-export from it (e.g. `pas-external`).
Documentation
//! OIDC discovery primitive — fetches
//! `<issuer>/.well-known/openid-configuration`.
//!
//! Phase A Slice 2 (RFC `RFC_2026-05-08_app-credential-collapse.md`)
//! moved this primitive from `pas-external::oidc::discovery` so any SDK
//! Relying-Party composition root (today: `pas-external`; tomorrow:
//! `pas-plims` / `pcs-external`) can compose it alongside the verifier
//! cohesive group.
//!
//! ── Discovery contract ──────────────────────────────────────────────────
//!
//! RFC 8414 §3 — the IdP publishes a JSON document at
//! `<issuer>/.well-known/openid-configuration` describing every
//! endpoint the RP needs (token, authorization, jwks_uri, userinfo,
//! …). Discovery is the standard way an RP avoids hard-coding IdP
//! endpoints.
//!
//! **Issuer-mismatch defense** — RFC 8414 §3.3: the document's
//! `issuer` field MUST equal the URL the RP fetched it from.
//! Otherwise an attacker who can serve a tampered discovery document
//! at `<their-domain>/.well-known/openid-configuration` could
//! redirect token exchange to their own endpoint. [`fetch_discovery`]
//! validates this and returns [`DiscoveryError::IssuerMismatch`] on
//! drift.

use serde::Deserialize;
use url::Url;

/// OIDC discovery document — minimal subset an RP cares about.
///
/// PAS publishes a richer document, but the RP needs only these
/// fields. Adding a field is one `pub` line + serde annotation.
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct Discovery {
    pub issuer: Url,
    pub authorization_endpoint: Url,
    pub token_endpoint: Url,
    pub jwks_uri: Url,
    #[serde(default)]
    pub userinfo_endpoint: Option<Url>,
}

#[cfg(any(test, feature = "test-support"))]
impl Discovery {
    /// Test-support constructor — bypasses the wire-deserialization
    /// path for tests that bypass [`fetch_discovery`].
    #[must_use]
    pub fn for_test(
        issuer: Url,
        authorization_endpoint: Url,
        token_endpoint: Url,
        jwks_uri: Url,
    ) -> Self {
        Self {
            issuer,
            authorization_endpoint,
            token_endpoint,
            jwks_uri,
            userinfo_endpoint: None,
        }
    }
}

#[derive(Debug, thiserror::Error)]
pub enum DiscoveryError {
    #[error("discovery fetch failed: {0}")]
    Fetch(String),
    #[error("discovery payload parse failed: {0}")]
    Parse(String),
    #[error("issuer mismatch: expected {expected}, got {actual}")]
    IssuerMismatch { expected: String, actual: String },
}

/// Fetch the OIDC discovery document from
/// `<issuer>/.well-known/openid-configuration`.
///
/// Validates that the document's `issuer` field matches the requested
/// `issuer` (RFC 8414 §3.3 — defense against substituted issuer).
///
/// # Errors
///
/// - [`DiscoveryError::Fetch`] — HTTP transport / non-2xx status
/// - [`DiscoveryError::Parse`] — payload not valid JSON or missing
///   required fields
/// - [`DiscoveryError::IssuerMismatch`] — payload `issuer` doesn't
///   match the requested issuer
pub async fn fetch_discovery(issuer: &Url) -> Result<Discovery, DiscoveryError> {
    // RFC 8414 §3 — discovery URL = issuer + /.well-known/openid-configuration.
    // `Url::set_path` replaces the path, so build the new path by
    // appending to the issuer's existing path (handles both root
    // `https://accounts.example.com` and sub-path
    // `https://example.com/auth` issuers).
    let mut url = issuer.clone();
    let new_path = format!(
        "{}/.well-known/openid-configuration",
        url.path().trim_end_matches('/')
    );
    url.set_path(&new_path);

    let response = reqwest::get(url)
        .await
        .map_err(|e| DiscoveryError::Fetch(e.to_string()))?;

    if !response.status().is_success() {
        return Err(DiscoveryError::Fetch(format!(
            "discovery returned HTTP {}",
            response.status()
        )));
    }

    let discovery: Discovery = response
        .json()
        .await
        .map_err(|e| DiscoveryError::Parse(e.to_string()))?;

    if discovery.issuer != *issuer {
        return Err(DiscoveryError::IssuerMismatch {
            expected: issuer.to_string(),
            actual: discovery.issuer.to_string(),
        });
    }

    Ok(discovery)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn fetch_discovery_with_invalid_url_yields_fetch_error() {
        // .invalid is RFC 6761-reserved as guaranteed-unresolvable;
        // the GET must fail at the transport layer.
        let issuer: Url = "http://nonexistent.invalid/".parse().expect("test url");
        let err = fetch_discovery(&issuer)
            .await
            .expect_err("bad URL must fail");
        assert!(
            matches!(err, DiscoveryError::Fetch(_)),
            "expected Fetch, got {err:?}"
        );
    }
}