1use serde::{Deserialize, Serialize};
2
3use crate::errors::{QueryComponent, build_well_known_url, metadata_error, normalize_issuer};
4use crate::transport::validate_fetch_url;
5use crate::{AuthplaneError, FetchSettings};
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8pub struct AuthorizationServerMetadata {
9 pub issuer: String,
10 pub jwks_uri: String,
11 #[serde(default)]
12 pub token_endpoint: Option<String>,
13 #[serde(default)]
14 pub introspection_endpoint: Option<String>,
15 #[serde(default)]
16 pub revocation_endpoint: Option<String>,
17}
18
19impl AuthorizationServerMetadata {
20 pub fn validate(
21 &self,
22 expected_issuer: &str,
23 settings: &FetchSettings,
24 ) -> Result<(), AuthplaneError> {
25 let normalized_expected = normalize_issuer(expected_issuer);
26 let normalized_actual = normalize_issuer(&self.issuer);
27
28 if normalized_actual.is_empty() {
29 return Err(metadata_error(
30 "AS metadata missing required 'issuer' field",
31 ));
32 }
33 if !normalized_expected.is_empty() && normalized_actual != normalized_expected {
34 return Err(metadata_error(&format!(
35 "AS metadata issuer mismatch: expected {normalized_expected:?}, got {normalized_actual:?}"
36 )));
37 }
38
39 validate_endpoint_url("jwks_uri", &self.jwks_uri, settings)?;
40 if let Some(value) = self.token_endpoint.as_deref() {
41 validate_endpoint_url("token_endpoint", value, settings)?;
42 }
43 if let Some(value) = self.introspection_endpoint.as_deref() {
44 validate_endpoint_url("introspection_endpoint", value, settings)?;
45 }
46 if let Some(value) = self.revocation_endpoint.as_deref() {
47 validate_endpoint_url("revocation_endpoint", value, settings)?;
48 }
49 Ok(())
50 }
51
52 pub fn token_endpoint(&self) -> Result<&str, AuthplaneError> {
53 self.token_endpoint
54 .as_deref()
55 .ok_or_else(|| missing_endpoint_error("token_endpoint"))
56 }
57
58 pub fn introspection_endpoint(&self) -> Result<&str, AuthplaneError> {
59 self.introspection_endpoint
60 .as_deref()
61 .ok_or_else(|| missing_endpoint_error("introspection_endpoint"))
62 }
63
64 pub fn revocation_endpoint(&self) -> Result<&str, AuthplaneError> {
65 self.revocation_endpoint
66 .as_deref()
67 .ok_or_else(|| missing_endpoint_error("revocation_endpoint"))
68 }
69}
70
71pub fn build_metadata_url(issuer: &str) -> Result<String, AuthplaneError> {
72 build_well_known_url(
76 issuer,
77 "oauth-authorization-server",
78 QueryComponent::Strip,
79 "metadata_fetch_error",
80 || "issuer must be an absolute URL".to_string(),
81 )
82}
83
84fn validate_endpoint_url(
85 field: &str,
86 value: &str,
87 settings: &FetchSettings,
88) -> Result<(), AuthplaneError> {
89 validate_fetch_url(value, settings, &format!("AS metadata field {field:?}")).map_err(|_| {
90 metadata_error(&format!(
91 "AS metadata field {field:?} failed fetch validation: {value:?}"
92 ))
93 })
94}
95
96fn missing_endpoint_error(field: &str) -> AuthplaneError {
97 crate::errors::auth_error(
98 "missing_metadata_endpoint",
99 &format!("AS metadata missing required '{field}' field"),
100 )
101}
102
103#[cfg(test)]
104mod tests {
105 use super::{AuthorizationServerMetadata, build_metadata_url};
106 use crate::FetchSettings;
107
108 #[test]
109 fn metadata_url_inserts_well_known_before_issuer_path() {
110 let url =
111 build_metadata_url("https://auth.example.com/team-a").expect("valid metadata url");
112 assert_eq!(
113 url,
114 "https://auth.example.com/.well-known/oauth-authorization-server/team-a"
115 );
116 }
117
118 #[test]
119 fn validation_rejects_non_https_endpoints_in_prod_mode() {
120 let metadata = AuthorizationServerMetadata {
121 issuer: "https://auth.example.com".to_string(),
122 jwks_uri: "http://auth.example.com/jwks".to_string(),
123 token_endpoint: None,
124 introspection_endpoint: None,
125 revocation_endpoint: None,
126 };
127
128 let error = metadata
129 .validate("https://auth.example.com", &FetchSettings::default())
130 .expect_err("http jwks should fail");
131 let crate::AuthplaneError::Auth(auth_error) = error else {
132 panic!("expected auth error");
133 };
134 assert_eq!(auth_error.code, "metadata_fetch_error");
135 }
136
137 #[test]
138 fn token_endpoint_accessor_requires_field() {
139 let metadata = AuthorizationServerMetadata {
140 issuer: "https://auth.example.com".to_string(),
141 jwks_uri: "https://auth.example.com/jwks".to_string(),
142 token_endpoint: None,
143 introspection_endpoint: None,
144 revocation_endpoint: None,
145 };
146 let error = metadata
147 .token_endpoint()
148 .expect_err("missing token endpoint");
149 let crate::AuthplaneError::Auth(auth_error) = error else {
150 panic!("expected auth error");
151 };
152 assert_eq!(auth_error.code, "missing_metadata_endpoint");
153 }
154
155 #[test]
156 fn metadata_url_for_root_issuer_has_no_suffix_path() {
157 let url = build_metadata_url("https://auth.example.com").expect("valid metadata url");
158 assert_eq!(
159 url,
160 "https://auth.example.com/.well-known/oauth-authorization-server"
161 );
162 }
163
164 #[test]
165 fn metadata_url_strips_trailing_slash_before_injecting_well_known() {
166 let url =
167 build_metadata_url("https://auth.example.com/tenant-a/").expect("valid metadata url");
168 assert_eq!(
169 url,
170 "https://auth.example.com/.well-known/oauth-authorization-server/tenant-a"
171 );
172 }
173
174 #[test]
175 fn metadata_url_drops_query_and_fragment() {
176 let url = build_metadata_url("https://auth.example.com/tenant-a?q=1#frag")
177 .expect("valid metadata url");
178 assert_eq!(
179 url,
180 "https://auth.example.com/.well-known/oauth-authorization-server/tenant-a"
181 );
182 }
183
184 #[test]
185 fn metadata_url_rejects_non_absolute_issuer() {
186 let error = build_metadata_url("/relative/path").expect_err("relative issuer rejected");
187 let crate::AuthplaneError::Auth(auth_error) = error else {
188 panic!("expected auth error");
189 };
190 assert_eq!(auth_error.code, "metadata_fetch_error");
191 }
192
193 #[test]
194 fn validate_accepts_issuer_with_equivalent_trailing_slash() {
195 let metadata = AuthorizationServerMetadata {
199 issuer: "https://auth.example.com/".to_string(),
200 jwks_uri: "https://auth.example.com/jwks.json".to_string(),
201 token_endpoint: None,
202 introspection_endpoint: None,
203 revocation_endpoint: None,
204 };
205 metadata
206 .validate("https://auth.example.com", &FetchSettings::default())
207 .expect("trailing-slash issuer must be treated as equal");
208 }
209
210 #[test]
211 fn validate_accepts_expected_issuer_with_trailing_slash() {
212 let metadata = AuthorizationServerMetadata {
213 issuer: "https://auth.example.com".to_string(),
214 jwks_uri: "https://auth.example.com/jwks.json".to_string(),
215 token_endpoint: None,
216 introspection_endpoint: None,
217 revocation_endpoint: None,
218 };
219 metadata
220 .validate("https://auth.example.com/", &FetchSettings::default())
221 .expect("trailing-slash on expected issuer must be treated as equal");
222 }
223
224 #[test]
225 fn validate_rejects_completely_different_issuer() {
226 let metadata = AuthorizationServerMetadata {
227 issuer: "https://attacker.example.net".to_string(),
228 jwks_uri: "https://auth.example.com/jwks.json".to_string(),
229 token_endpoint: None,
230 introspection_endpoint: None,
231 revocation_endpoint: None,
232 };
233 let error = metadata
234 .validate("https://auth.example.com", &FetchSettings::default())
235 .expect_err("issuer mismatch must be rejected");
236 let crate::AuthplaneError::Auth(auth_error) = error else {
237 panic!("expected auth error");
238 };
239 assert_eq!(auth_error.code, "metadata_fetch_error");
240 assert!(auth_error.message.contains("issuer mismatch"));
241 }
242
243 #[test]
244 fn validate_happy_path_accepts_full_metadata() {
245 let metadata = AuthorizationServerMetadata {
246 issuer: "https://auth.example.com".to_string(),
247 jwks_uri: "https://auth.example.com/jwks.json".to_string(),
248 token_endpoint: Some("https://auth.example.com/oauth/token".to_string()),
249 introspection_endpoint: Some("https://auth.example.com/oauth/introspect".to_string()),
250 revocation_endpoint: Some("https://auth.example.com/oauth/revoke".to_string()),
251 };
252 metadata
253 .validate("https://auth.example.com", &FetchSettings::default())
254 .expect("fully populated https metadata must validate");
255 }
256
257 #[test]
258 fn introspection_endpoint_accessor_requires_field() {
259 let metadata = AuthorizationServerMetadata {
260 issuer: "https://auth.example.com".to_string(),
261 jwks_uri: "https://auth.example.com/jwks.json".to_string(),
262 token_endpoint: Some("https://auth.example.com/oauth/token".to_string()),
263 introspection_endpoint: None,
264 revocation_endpoint: None,
265 };
266 let error = metadata
267 .introspection_endpoint()
268 .expect_err("missing introspection endpoint must fail");
269 let crate::AuthplaneError::Auth(auth_error) = error else {
270 panic!("expected auth error");
271 };
272 assert_eq!(auth_error.code, "missing_metadata_endpoint");
273 }
274
275 #[test]
276 fn revocation_endpoint_accessor_requires_field() {
277 let metadata = AuthorizationServerMetadata {
278 issuer: "https://auth.example.com".to_string(),
279 jwks_uri: "https://auth.example.com/jwks.json".to_string(),
280 token_endpoint: Some("https://auth.example.com/oauth/token".to_string()),
281 introspection_endpoint: None,
282 revocation_endpoint: None,
283 };
284 let error = metadata
285 .revocation_endpoint()
286 .expect_err("missing revocation endpoint must fail");
287 let crate::AuthplaneError::Auth(auth_error) = error else {
288 panic!("expected auth error");
289 };
290 assert_eq!(auth_error.code, "missing_metadata_endpoint");
291 }
292}