Skip to main content

pdk_token_introspection_lib/
discovery.rs

1// Copyright (c) 2026, Salesforce, Inc.,
2// All rights reserved.
3// For full license text, see the LICENSE.txt file
4
5//! OAuth 2.0 Protected Resource Discovery (RFC 9728) implementation.
6//!
7//! This module provides the [`ProtectedResourceMetadata`] which implements
8//! RFC 9728 for OAuth 2.0 Protected Resource Metadata. It handles:
9//!
10//! - Serving the metadata endpoint at `{api_base_path}/.well-known/oauth-protected-resource`
11//! - Returning `401 Unauthorized` with `WWW-Authenticate` header for unauthenticated requests
12//! - Building RFC 9728 compliant `401` responses for use when token validation fails
13//!
14//! The API base path is passed in from PDK metadata at configure time so the well-known
15//! endpoint is always served relative to the API, not the host root.
16//!
17//! The request origin (`scheme://authority`) is provided per-request and combined with
18//! the API base path to build the `resource` identifier and `resource_metadata` URL.
19
20use 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/// Errors that can occur during discovery handler building.
34#[non_exhaustive]
35#[derive(Error, Debug)]
36pub enum ProtectedResourceMetadataBuildError {
37    #[error("Authorization server URL is required but not provided.")]
38    MissingAuthorizationServerUrl,
39}
40
41/// Created internally by [`crate::introspector::TokenValidatorBuilderInstance::with_protected_resource_metadata`].
42/// The API base path is passed in automatically from PDK metadata extracted at configure time.
43///
44/// This can be built directly by any policy that needs protected-resource metadata, independent of the token introspection flow.
45pub struct ProtectedResourceMetadataBuilder {
46    authorization_server_url: Option<String>,
47    additional_properties: Option<Value>,
48    api_base_path: Option<String>,
49}
50
51impl ProtectedResourceMetadataBuilder {
52    /// Creates a builder for the given API base path (from PDK metadata).
53    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    /// Sets the authorization server URL advertised in the metadata document.
62    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    /// Sets additional optional metadata properties (e.g. `scopes_supported`, `bearer_methods_supported`).
68    pub(crate) fn with_metadata_properties(mut self, properties: Value) -> Self {
69        self.additional_properties = Some(properties);
70        self
71    }
72
73    /// Builds the [`ProtectedResourceMetadata`].
74    ///
75    /// The API base path (from PDK Metadata) is used to place the well-known
76    /// endpoint under the API's own path prefix. For example, if the API
77    /// listens on `/api`, the metadata endpoint will be
78    /// `/api/.well-known/oauth-protected-resource`.
79    ///
80    /// # Errors
81    ///
82    /// Returns an error if `authorization_server_url` is missing — call
83    /// [`with_authorization_server`](Self::with_authorization_server).
84    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        // Build the metadata endpoint path:
102        //   {api_base_path}/.well-known/oauth-protected-resource
103        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/// OAuth 2.0 Protected Resource Metadata (RFC 9728).
122///
123/// Handles the server-side discovery flow:
124///
125/// 1. **Metadata endpoint** — serves resource metadata JSON at the well-known
126///    path (GET only, per RFC 9728 §3.1).
127/// 2. **Invalid token helper** — [`unauthorized_response`](Self::unauthorized_response)
128///    builds a 401 for use after token validation failure.
129///
130/// The well-known path is placed under the API base path, e.g.
131/// `/api/.well-known/oauth-protected-resource`.
132///
133/// All methods take the request `origin` (`scheme://authority`) and combine it
134/// with the API base path to build the `resource` identifier and
135/// `resource_metadata` URL.
136///
137/// Use [`ProtectedResourceMetadataBuilder`] to create instances of this type.
138#[derive(Clone, Debug)]
139pub struct ProtectedResourceMetadata {
140    config: ProtectedResourceConfig,
141    metadata_endpoint_path: String,
142    /// Normalized API base path (e.g. `/api`), empty when no base path.
143    api_base_path: String,
144}
145
146impl ProtectedResourceMetadata {
147    /// Builds the metadata response if the request matches the well-known endpoint.
148    ///
149    /// The `origin` parameter (`scheme://authority`) is combined with the API
150    /// base path to produce the `resource` field in the metadata response.
151    ///
152    /// Returns `Some(response)` when the request matches; `None` when it does not,
153    /// meaning the caller should continue to normal request processing.
154    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    /// Builds a 401 response with `WWW-Authenticate` including `resource_metadata`.
173    ///
174    /// The `origin` parameter (`scheme://authority`) is combined with the
175    /// metadata endpoint path to produce the `resource_metadata` URL.
176    ///
177    /// `www_authenticate_extra` is an optional string to include before the
178    /// `resource_metadata` hint (e.g. `realm="My API"` produces
179    /// `Bearer realm="My API", resource_metadata="..."`).
180    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    /// Internal: builds the 401 response given a fully-qualified metadata URL.
191    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    /// Builds a 200 metadata response (RFC 9728 §3.2).
203    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        // Merge additional properties (scopes_supported, etc.)
211        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    // --- Builder tests ---
256
257    #[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    // --- Endpoint path tests ---
273
274    #[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    // --- Metadata response tests ---
329
330    #[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    // --- Matching tests ---
414
415    #[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        // POST to the well-known path should not serve metadata (RFC 9728 §3.1)
427        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        // With /api base path, the bare well-known path should not match
451        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    // --- 401 response tests ---
478
479    #[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    // --- Origin + base path integration tests ---
529
530    #[test]
531    fn origin_with_nested_base_path() {
532        let h = make_handler(Some("/api/v1"));
533
534        // 401 points to correct metadata URL
535        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        // Metadata response has correct resource
542        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}