pdk_token_introspection_lib/
discovery.rs1use pdk_core::classy::hl::Response;
21use pdk_core::log::debug;
22use pdk_script::Value;
23use serde_json::json;
24use thiserror::Error;
25
26use crate::discovery_config::ProtectedResourceConfig;
27
28const CONTENT_TYPE_JSON: &str = "application/json";
29const CONTENT_TYPE: &str = "Content-Type";
30const WELL_KNOWN_PATH: &str = "/.well-known/oauth-protected-resource";
31const HTTP_METHOD_GET: &str = "GET";
32
33#[non_exhaustive]
35#[derive(Error, Debug)]
36pub enum ProtectedResourceMetadataBuildError {
37 #[error("Authorization server URL is required but not provided.")]
38 MissingAuthorizationServerUrl,
39}
40
41pub struct ProtectedResourceMetadataBuilder {
46 authorization_server_url: Option<String>,
47 additional_properties: Option<Value>,
48 api_base_path: Option<String>,
49}
50
51impl ProtectedResourceMetadataBuilder {
52 pub fn new(api_base_path: Option<String>) -> Self {
54 ProtectedResourceMetadataBuilder {
55 authorization_server_url: None,
56 additional_properties: None,
57 api_base_path,
58 }
59 }
60
61 pub fn with_authorization_server(mut self, url: impl Into<String>) -> Self {
63 self.authorization_server_url = Some(url.into());
64 self
65 }
66
67 pub(crate) fn with_metadata_properties(mut self, properties: Value) -> Self {
69 self.additional_properties = Some(properties);
70 self
71 }
72
73 pub fn build(self) -> Result<ProtectedResourceMetadata, ProtectedResourceMetadataBuildError> {
85 let authorization_server_url = self
86 .authorization_server_url
87 .ok_or(ProtectedResourceMetadataBuildError::MissingAuthorizationServerUrl)?;
88
89 let api_base_path = self
90 .api_base_path
91 .map(|path| {
92 let path = if path.starts_with('/') {
93 path
94 } else {
95 format!("/{path}")
96 };
97 path.trim_end_matches('/').to_string()
98 })
99 .filter(|path| !path.is_empty());
100
101 let base = api_base_path.as_deref().unwrap_or("");
104 let metadata_endpoint_path = format!("{base}{WELL_KNOWN_PATH}");
105
106 debug!(
107 "Protected resource metadata configured: metadata_endpoint_path={metadata_endpoint_path}"
108 );
109
110 Ok(ProtectedResourceMetadata {
111 config: ProtectedResourceConfig {
112 authorization_server_url,
113 additional_properties: self.additional_properties,
114 },
115 metadata_endpoint_path,
116 api_base_path: base.to_string(),
117 })
118 }
119}
120
121#[derive(Clone, Debug)]
139pub struct ProtectedResourceMetadata {
140 config: ProtectedResourceConfig,
141 metadata_endpoint_path: String,
142 api_base_path: String,
144}
145
146impl ProtectedResourceMetadata {
147 pub fn build_metadata_response(
155 &self,
156 path: &str,
157 method: &str,
158 origin: &str,
159 ) -> Option<Response> {
160 if path == self.metadata_endpoint_path && method.eq_ignore_ascii_case(HTTP_METHOD_GET) {
161 debug!(
162 "Serving resource metadata at {}",
163 self.metadata_endpoint_path
164 );
165 let origin = origin.trim_end_matches('/');
166 let resource_url = format!("{origin}{}", self.api_base_path);
167 return Some(self.build_metadata_json_response(&resource_url));
168 }
169 None
170 }
171
172 pub fn unauthorized_response(
181 &self,
182 origin: &str,
183 www_authenticate_extra: Option<&str>,
184 ) -> Response {
185 let origin = origin.trim_end_matches('/');
186 let metadata_url = format!("{origin}{}", self.metadata_endpoint_path);
187 Self::build_unauthorized_response(&metadata_url, www_authenticate_extra)
188 }
189
190 fn build_unauthorized_response(
192 metadata_url: &str,
193 www_authenticate_extra: Option<&str>,
194 ) -> Response {
195 let www_authenticate = match www_authenticate_extra {
196 Some(extra) => format!("Bearer {extra}, resource_metadata=\"{metadata_url}\""),
197 None => format!("Bearer resource_metadata=\"{metadata_url}\""),
198 };
199 Response::new(401).with_headers(vec![("WWW-Authenticate".to_string(), www_authenticate)])
200 }
201
202 fn build_metadata_json_response(&self, resource_url: &str) -> Response {
204 let resource_url = resource_url.trim_end_matches('/');
205 let mut metadata = json!({
206 "resource": resource_url,
207 "authorization_servers": [self.config.authorization_server_url],
208 });
209
210 if let Some(additional) = &self.config.additional_properties {
212 let additional_json: serde_json::Value = additional.clone().into();
213 if let (Some(base), Some(extra)) =
214 (metadata.as_object_mut(), additional_json.as_object())
215 {
216 for (key, value) in extra {
217 base.insert(key.clone(), value.clone());
218 }
219 }
220 }
221
222 Response::new(200)
223 .with_headers(vec![(
224 CONTENT_TYPE.to_string(),
225 CONTENT_TYPE_JSON.to_string(),
226 )])
227 .with_body(metadata.to_string())
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 fn make_handler(api_base_path: Option<&str>) -> ProtectedResourceMetadata {
236 ProtectedResourceMetadataBuilder::new(api_base_path.map(str::to_string))
237 .with_authorization_server("https://auth.example.com")
238 .build()
239 .unwrap()
240 }
241
242 fn get_header(response: &Response, name: &str) -> Option<String> {
243 response
244 .headers()
245 .iter()
246 .find(|(k, _)| k.eq_ignore_ascii_case(name))
247 .map(|(_, v)| v.to_string())
248 }
249
250 fn get_body_json(response: &Response) -> serde_json::Value {
251 let bytes = response.body().expect("should have body");
252 serde_json::from_slice(bytes).expect("should be valid JSON")
253 }
254
255 #[test]
258 fn build_fails_without_authorization_server() {
259 let b = ProtectedResourceMetadataBuilder::new(None);
260 assert!(matches!(
261 b.build().unwrap_err(),
262 ProtectedResourceMetadataBuildError::MissingAuthorizationServerUrl
263 ));
264 }
265
266 #[test]
267 fn build_error_message() {
268 let e = ProtectedResourceMetadataBuildError::MissingAuthorizationServerUrl;
269 assert!(!e.to_string().is_empty());
270 }
271
272 #[test]
275 fn endpoint_path_without_base_path() {
276 let h = make_handler(None);
277 assert_eq!(
278 h.metadata_endpoint_path,
279 "/.well-known/oauth-protected-resource"
280 );
281 }
282
283 #[test]
284 fn endpoint_path_with_base_path() {
285 let h = make_handler(Some("/api"));
286 assert_eq!(
287 h.metadata_endpoint_path,
288 "/api/.well-known/oauth-protected-resource"
289 );
290 }
291
292 #[test]
293 fn endpoint_path_normalizes_missing_leading_slash() {
294 let h = make_handler(Some("api"));
295 assert_eq!(
296 h.metadata_endpoint_path,
297 "/api/.well-known/oauth-protected-resource"
298 );
299 }
300
301 #[test]
302 fn endpoint_path_normalizes_trailing_slash() {
303 let h = make_handler(Some("/api/"));
304 assert_eq!(
305 h.metadata_endpoint_path,
306 "/api/.well-known/oauth-protected-resource"
307 );
308 }
309
310 #[test]
311 fn endpoint_path_treats_root_slash_as_no_base() {
312 let h = make_handler(Some("/"));
313 assert_eq!(
314 h.metadata_endpoint_path,
315 "/.well-known/oauth-protected-resource"
316 );
317 }
318
319 #[test]
320 fn endpoint_path_with_nested_base_path() {
321 let h = make_handler(Some("/api/v1"));
322 assert_eq!(
323 h.metadata_endpoint_path,
324 "/api/v1/.well-known/oauth-protected-resource"
325 );
326 }
327
328 #[test]
331 fn metadata_response_returns_200_json() {
332 let h = make_handler(None);
333 let resp = h
334 .build_metadata_response(
335 "/.well-known/oauth-protected-resource",
336 "GET",
337 "https://test.com",
338 )
339 .unwrap();
340
341 assert_eq!(resp.status_code(), 200);
342 assert_eq!(
343 get_header(&resp, "Content-Type").as_deref(),
344 Some("application/json")
345 );
346
347 let body = get_body_json(&resp);
348 assert_eq!(body["resource"], "https://test.com");
349 assert_eq!(
350 body["authorization_servers"],
351 serde_json::json!(["https://auth.example.com"])
352 );
353 }
354
355 #[test]
356 fn metadata_response_merges_additional_properties() {
357 let h = ProtectedResourceMetadataBuilder::new(None)
358 .with_authorization_server("https://auth.example.com")
359 .with_metadata_properties(Value::Object(
360 [
361 (
362 "scopes_supported".to_string(),
363 Value::Array(vec![
364 Value::String("read".to_string()),
365 Value::String("write".to_string()),
366 ]),
367 ),
368 (
369 "bearer_methods_supported".to_string(),
370 Value::Array(vec![Value::String("header".to_string())]),
371 ),
372 ]
373 .into(),
374 ))
375 .build()
376 .unwrap();
377
378 let resp = h
379 .build_metadata_response(
380 "/.well-known/oauth-protected-resource",
381 "GET",
382 "https://test.com",
383 )
384 .unwrap();
385 let body = get_body_json(&resp);
386
387 assert_eq!(body["resource"], "https://test.com");
388 assert_eq!(
389 body["scopes_supported"],
390 serde_json::json!(["read", "write"])
391 );
392 assert_eq!(
393 body["bearer_methods_supported"],
394 serde_json::json!(["header"])
395 );
396 }
397
398 #[test]
399 fn metadata_response_normalizes_trailing_slash_in_origin() {
400 let h = make_handler(None);
401 let resp = h
402 .build_metadata_response(
403 "/.well-known/oauth-protected-resource",
404 "GET",
405 "https://test.com/",
406 )
407 .unwrap();
408
409 let body = get_body_json(&resp);
410 assert_eq!(body["resource"], "https://test.com");
411 }
412
413 #[test]
416 fn returns_none_for_non_metadata_path() {
417 let h = make_handler(None);
418 assert!(h
419 .build_metadata_response("/some/other/path", "GET", "https://test.com")
420 .is_none());
421 }
422
423 #[test]
424 fn returns_none_for_post() {
425 let h = make_handler(None);
426 assert!(h
428 .build_metadata_response(
429 "/.well-known/oauth-protected-resource",
430 "POST",
431 "https://test.com",
432 )
433 .is_none());
434 }
435
436 #[test]
437 fn returns_none_for_query_parameters() {
438 let h = make_handler(None);
439 assert!(h
440 .build_metadata_response(
441 "/.well-known/oauth-protected-resource?foo=bar",
442 "GET",
443 "https://test.com",
444 )
445 .is_none());
446 }
447
448 #[test]
449 fn returns_none_without_base_path_prefix() {
450 let h = make_handler(Some("/api"));
452 assert!(h
453 .build_metadata_response(
454 "/.well-known/oauth-protected-resource",
455 "GET",
456 "https://test.com",
457 )
458 .is_none());
459 }
460
461 #[test]
462 fn returns_response_with_base_path() {
463 let h = make_handler(Some("/api"));
464 let resp = h
465 .build_metadata_response(
466 "/api/.well-known/oauth-protected-resource",
467 "GET",
468 "https://test.com",
469 )
470 .unwrap();
471
472 assert_eq!(resp.status_code(), 200);
473 let body = get_body_json(&resp);
474 assert_eq!(body["resource"], "https://test.com/api");
475 }
476
477 #[test]
480 fn unauthorized_response_with_base_path() {
481 let h = make_handler(Some("/api"));
482 let resp = h.unauthorized_response("https://test.com", None);
483
484 assert_eq!(resp.status_code(), 401);
485 assert_eq!(
486 get_header(&resp, "WWW-Authenticate").unwrap(),
487 "Bearer resource_metadata=\"https://test.com/api/.well-known/oauth-protected-resource\""
488 );
489 }
490
491 #[test]
492 fn unauthorized_response_without_base_path() {
493 let h = make_handler(None);
494 let resp = h.unauthorized_response("https://test.com", None);
495
496 assert_eq!(resp.status_code(), 401);
497 assert_eq!(
498 get_header(&resp, "WWW-Authenticate").unwrap(),
499 "Bearer resource_metadata=\"https://test.com/.well-known/oauth-protected-resource\""
500 );
501 }
502
503 #[test]
504 fn unauthorized_response_with_extra() {
505 let h = make_handler(Some("/api"));
506 let resp = h.unauthorized_response(
507 "https://test.com",
508 Some("realm=\"OAuth2 Introspection Client Realm\""),
509 );
510
511 assert_eq!(resp.status_code(), 401);
512 assert_eq!(
513 get_header(&resp, "WWW-Authenticate").unwrap(),
514 "Bearer realm=\"OAuth2 Introspection Client Realm\", resource_metadata=\"https://test.com/api/.well-known/oauth-protected-resource\""
515 );
516 }
517
518 #[test]
519 fn unauthorized_response_normalizes_trailing_slash() {
520 let h = make_handler(Some("/api"));
521 let resp = h.unauthorized_response("https://test.com/", None);
522
523 let www_auth = get_header(&resp, "WWW-Authenticate").unwrap();
524 assert!(!www_auth.contains("//.well-known"));
525 assert!(www_auth.contains("https://test.com/api/.well-known"));
526 }
527
528 #[test]
531 fn origin_with_nested_base_path() {
532 let h = make_handler(Some("/api/v1"));
533
534 let resp = h.unauthorized_response("https://test.com", None);
536 assert_eq!(
537 get_header(&resp, "WWW-Authenticate").unwrap(),
538 "Bearer resource_metadata=\"https://test.com/api/v1/.well-known/oauth-protected-resource\""
539 );
540
541 let resp = h
543 .build_metadata_response(
544 "/api/v1/.well-known/oauth-protected-resource",
545 "GET",
546 "https://test.com",
547 )
548 .unwrap();
549
550 let body = get_body_json(&resp);
551 assert_eq!(body["resource"], "https://test.com/api/v1");
552 }
553}