Skip to main content

axum_security_oidc/
metadata.rs

1use axum_security_oauth2::HttpClient;
2use serde::Deserialize;
3use url::Url;
4
5use crate::error::DiscoveryError;
6
7/// OpenID Connect provider metadata, from a `.well-known/openid-configuration`
8/// document (OpenID Connect Discovery 1.0 §3).
9///
10/// Only the fields this crate uses are modelled as named fields; everything
11/// else the provider publishes is kept in [`extra`](Self::extra).
12#[derive(Debug, Clone, Deserialize)]
13pub struct ProviderMetadata {
14    /// The issuer identifier. Must match the requested issuer URL.
15    pub issuer: String,
16    /// The authorization endpoint (`authorization_endpoint`).
17    pub authorization_endpoint: String,
18    /// The token endpoint (`token_endpoint`).
19    pub token_endpoint: String,
20    /// The JWK Set URL (`jwks_uri`) — where ID-token signing keys live.
21    pub jwks_uri: String,
22    /// The UserInfo endpoint, if advertised.
23    #[serde(default)]
24    pub userinfo_endpoint: Option<String>,
25    /// The RP-initiated logout endpoint, if advertised.
26    #[serde(default)]
27    pub end_session_endpoint: Option<String>,
28    /// The `id_token` signing algorithms the provider supports, if advertised.
29    #[serde(default)]
30    pub id_token_signing_alg_values_supported: Option<Vec<String>>,
31    /// Every other field the metadata document carried.
32    #[serde(flatten)]
33    pub extra: serde_json::Map<String, serde_json::Value>,
34}
35
36impl ProviderMetadata {
37    /// Fetch and validate provider metadata for `issuer_url`.
38    ///
39    /// Requests `{issuer_url}/.well-known/openid-configuration` over `http` and
40    /// enforces the Discovery §4.3 issuer-match check: the `issuer` in the
41    /// returned document must equal the requested issuer (a trailing slash on
42    /// either side is ignored).
43    pub async fn discover(
44        issuer_url: &str,
45        http: &HttpClient,
46    ) -> Result<ProviderMetadata, DiscoveryError> {
47        let config_url = discovery_url(issuer_url)?;
48
49        let response = http.get(&config_url).await.map_err(DiscoveryError::Http)?;
50
51        if !response.is_success() {
52            return Err(DiscoveryError::Status(response.status));
53        }
54
55        let metadata: ProviderMetadata =
56            serde_json::from_slice(&response.body).map_err(DiscoveryError::Parse)?;
57
58        // Discovery §4.3: the issuer in the document must match the one we asked
59        // for, or a hostile endpoint could point us at another provider's keys.
60        if !issuer_matches(issuer_url, &metadata.issuer) {
61            return Err(DiscoveryError::IssuerMismatch);
62        }
63
64        Ok(metadata)
65    }
66}
67
68/// Build `{issuer}/.well-known/openid-configuration`, avoiding a double slash.
69fn discovery_url(issuer_url: &str) -> Result<Url, DiscoveryError> {
70    let base = issuer_url.trim_end_matches('/');
71    Url::parse(&format!("{base}/.well-known/openid-configuration"))
72        .map_err(DiscoveryError::InvalidIssuerUrl)
73}
74
75/// Compare issuer identifiers, tolerating a trailing slash on either side.
76fn issuer_matches(requested: &str, returned: &str) -> bool {
77    requested.trim_end_matches('/') == returned.trim_end_matches('/')
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn builds_discovery_url() {
86        assert_eq!(
87            discovery_url("https://accounts.google.com")
88                .unwrap()
89                .as_str(),
90            "https://accounts.google.com/.well-known/openid-configuration"
91        );
92        // Trailing slash does not double up.
93        assert_eq!(
94            discovery_url("https://accounts.google.com/")
95                .unwrap()
96                .as_str(),
97            "https://accounts.google.com/.well-known/openid-configuration"
98        );
99    }
100
101    #[test]
102    fn issuer_match_tolerates_trailing_slash() {
103        assert!(issuer_matches(
104            "https://issuer.example/",
105            "https://issuer.example"
106        ));
107        assert!(issuer_matches(
108            "https://issuer.example",
109            "https://issuer.example/"
110        ));
111        assert!(!issuer_matches(
112            "https://issuer.example",
113            "https://evil.example"
114        ));
115    }
116
117    #[test]
118    fn deserializes_metadata_with_extra_fields() {
119        let json = r#"{
120            "issuer": "https://issuer.example",
121            "authorization_endpoint": "https://issuer.example/auth",
122            "token_endpoint": "https://issuer.example/token",
123            "jwks_uri": "https://issuer.example/jwks",
124            "end_session_endpoint": "https://issuer.example/logout",
125            "id_token_signing_alg_values_supported": ["RS256", "ES256"],
126            "scopes_supported": ["openid", "email"]
127        }"#;
128        let m: ProviderMetadata = serde_json::from_str(json).unwrap();
129        assert_eq!(m.issuer, "https://issuer.example");
130        assert_eq!(m.jwks_uri, "https://issuer.example/jwks");
131        assert_eq!(
132            m.end_session_endpoint.as_deref(),
133            Some("https://issuer.example/logout")
134        );
135        assert_eq!(
136            m.id_token_signing_alg_values_supported.as_deref(),
137            Some(&["RS256".to_string(), "ES256".to_string()][..])
138        );
139        assert!(m.userinfo_endpoint.is_none());
140        assert!(m.extra.contains_key("scopes_supported"));
141    }
142}