Skip to main content

ppoppo_sdk_core/
discovery.rs

1//! OIDC discovery primitive — fetches
2//! `<issuer>/.well-known/openid-configuration`.
3//!
4//! Phase A Slice 2 (RFC `RFC_2026-05-08_app-credential-collapse.md`)
5//! moved this primitive from `pas-external::oidc::discovery` so any SDK
6//! Relying-Party composition root (today: `pas-external`; tomorrow:
7//! `pas-plims` / `pcs-external`) can compose it alongside the verifier
8//! cohesive group.
9//!
10//! ── Discovery contract ──────────────────────────────────────────────────
11//!
12//! RFC 8414 §3 — the IdP publishes a JSON document at
13//! `<issuer>/.well-known/openid-configuration` describing every
14//! endpoint the RP needs (token, authorization, jwks_uri, userinfo,
15//! …). Discovery is the standard way an RP avoids hard-coding IdP
16//! endpoints.
17//!
18//! **Issuer-mismatch defense** — RFC 8414 §3.3: the document's
19//! `issuer` field MUST equal the URL the RP fetched it from.
20//! Otherwise an attacker who can serve a tampered discovery document
21//! at `<their-domain>/.well-known/openid-configuration` could
22//! redirect token exchange to their own endpoint. [`fetch_discovery`]
23//! validates this and returns [`DiscoveryError::IssuerMismatch`] on
24//! drift.
25
26use serde::Deserialize;
27use url::Url;
28
29/// OIDC discovery document — minimal subset an RP cares about.
30///
31/// PAS publishes a richer document, but the RP needs only these
32/// fields. Adding a field is one `pub` line + serde annotation.
33#[derive(Debug, Clone, Deserialize)]
34#[non_exhaustive]
35pub struct Discovery {
36    pub issuer: Url,
37    pub authorization_endpoint: Url,
38    pub token_endpoint: Url,
39    pub jwks_uri: Url,
40    #[serde(default)]
41    pub userinfo_endpoint: Option<Url>,
42    /// OIDC RP-Initiated Logout 1.0 §2 — the OP `end_session_endpoint`.
43    /// `None` when the OP advertises no RP-initiated logout support
44    /// (the field is OPTIONAL in OIDC discovery).
45    #[serde(default)]
46    pub end_session_endpoint: Option<Url>,
47}
48
49#[cfg(any(test, feature = "test-support"))]
50impl Discovery {
51    /// Test-support constructor — bypasses the wire-deserialization
52    /// path for tests that bypass [`fetch_discovery`].
53    #[must_use]
54    pub fn for_test(
55        issuer: Url,
56        authorization_endpoint: Url,
57        token_endpoint: Url,
58        jwks_uri: Url,
59    ) -> Self {
60        Self {
61            issuer,
62            authorization_endpoint,
63            token_endpoint,
64            jwks_uri,
65            userinfo_endpoint: None,
66            end_session_endpoint: None,
67        }
68    }
69
70    /// Test-support: set the `end_session_endpoint` (RP-Initiated Logout).
71    #[must_use]
72    pub fn with_end_session_endpoint(mut self, endpoint: Url) -> Self {
73        self.end_session_endpoint = Some(endpoint);
74        self
75    }
76}
77
78#[derive(Debug, thiserror::Error)]
79pub enum DiscoveryError {
80    #[error("discovery fetch failed: {0}")]
81    Fetch(String),
82    #[error("discovery payload parse failed: {0}")]
83    Parse(String),
84    #[error("issuer mismatch: expected {expected}, got {actual}")]
85    IssuerMismatch { expected: String, actual: String },
86}
87
88/// Fetch the OIDC discovery document from
89/// `<issuer>/.well-known/openid-configuration`.
90///
91/// Validates that the document's `issuer` field matches the requested
92/// `issuer` (RFC 8414 §3.3 — defense against substituted issuer).
93///
94/// # Errors
95///
96/// - [`DiscoveryError::Fetch`] — HTTP transport / non-2xx status
97/// - [`DiscoveryError::Parse`] — payload not valid JSON or missing
98///   required fields
99/// - [`DiscoveryError::IssuerMismatch`] — payload `issuer` doesn't
100///   match the requested issuer
101pub async fn fetch_discovery(issuer: &Url) -> Result<Discovery, DiscoveryError> {
102    // RFC 8414 §3 — discovery URL = issuer + /.well-known/openid-configuration.
103    // `Url::set_path` replaces the path, so build the new path by
104    // appending to the issuer's existing path (handles both root
105    // `https://accounts.example.com` and sub-path
106    // `https://example.com/auth` issuers).
107    let mut url = issuer.clone();
108    let new_path = format!(
109        "{}/.well-known/openid-configuration",
110        url.path().trim_end_matches('/')
111    );
112    url.set_path(&new_path);
113
114    // `reqwest::get` builds a Client internally — with `rustls-no-provider`
115    // that construction needs the process-level CryptoProvider installed.
116    crate::install_ring_provider();
117    let response = reqwest::get(url)
118        .await
119        .map_err(|e| DiscoveryError::Fetch(e.to_string()))?;
120
121    if !response.status().is_success() {
122        return Err(DiscoveryError::Fetch(format!(
123            "discovery returned HTTP {}",
124            response.status()
125        )));
126    }
127
128    let discovery: Discovery = response
129        .json()
130        .await
131        .map_err(|e| DiscoveryError::Parse(e.to_string()))?;
132
133    if discovery.issuer != *issuer {
134        return Err(DiscoveryError::IssuerMismatch {
135            expected: issuer.to_string(),
136            actual: discovery.issuer.to_string(),
137        });
138    }
139
140    Ok(discovery)
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use std::assert_matches;
147
148    #[tokio::test]
149    async fn fetch_discovery_with_invalid_url_yields_fetch_error() {
150        // .invalid is RFC 6761-reserved as guaranteed-unresolvable;
151        // the GET must fail at the transport layer.
152        let issuer: Url = "http://nonexistent.invalid/".parse().expect("test url");
153        let err = fetch_discovery(&issuer)
154            .await
155            .expect_err("bad URL must fail");
156        assert_matches!(err, DiscoveryError::Fetch(_), "expected Fetch, got {err:?}");
157    }
158}