Skip to main content

authplane_sdk/
metadata.rs

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    // RFC 8414 §2 gives the issuer identifier no query or fragment
73    // components, so a query on the input is out-of-spec noise and is
74    // dropped rather than carried into the metadata URL.
75    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        // RFC 8414 §2 issuer normalization: a trailing `/` on the
196        // configured issuer must match metadata whose `issuer` field
197        // does not include one (and vice versa).
198        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}