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}