pas-external 0.8.0-beta.1

Ppoppo Accounts System (PAS) external SDK — OAuth2 PKCE, JWT verification port, Axum middleware, session liveness
Documentation
//! OIDC discovery primitive — fetches
//! `<issuer>/.well-known/openid-configuration`.
//!
//! Phase 11.A round-3 audit extracted this primitive so
//! [`super::RelyingParty::new`] can compose it alongside
//! [`super::PasIdTokenVerifier`]. Phase 10's verifier accepts a JWKS
//! URL directly (no discovery); the RP needs full discovery because
//! `RelyingParty::new` takes only an `issuer` URL and resolves
//! everything else from the discovery payload.
//!
//! ── 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 the 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`] (e.g.,
    /// boundary tests using
    /// [`super::RelyingParty::for_test_with_parts`]).
    #[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:?}"
        );
    }
}