axum_security_oidc/
metadata.rs1use axum_security_oauth2::HttpClient;
2use serde::Deserialize;
3use url::Url;
4
5use crate::error::DiscoveryError;
6
7#[derive(Debug, Clone, Deserialize)]
13pub struct ProviderMetadata {
14 pub issuer: String,
16 pub authorization_endpoint: String,
18 pub token_endpoint: String,
20 pub jwks_uri: String,
22 #[serde(default)]
24 pub userinfo_endpoint: Option<String>,
25 #[serde(default)]
27 pub end_session_endpoint: Option<String>,
28 #[serde(default)]
30 pub id_token_signing_alg_values_supported: Option<Vec<String>>,
31 #[serde(flatten)]
33 pub extra: serde_json::Map<String, serde_json::Value>,
34}
35
36impl ProviderMetadata {
37 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 if !issuer_matches(issuer_url, &metadata.issuer) {
61 return Err(DiscoveryError::IssuerMismatch);
62 }
63
64 Ok(metadata)
65 }
66}
67
68fn 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
75fn 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 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}