Skip to main content

authplane_sdk/
prm.rs

1use serde::{Deserialize, Serialize};
2
3use crate::AuthplaneError;
4use crate::errors::{QueryComponent, auth_error, build_well_known_url};
5
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7pub struct ProtectedResourceMetadata {
8    pub resource: String,
9    pub authorization_servers: Vec<String>,
10    pub bearer_methods_supported: Vec<String>,
11    pub scopes_supported: Vec<String>,
12    #[serde(skip_serializing_if = "Option::is_none")]
13    pub dpop_signing_alg_values_supported: Option<Vec<String>>,
14    #[serde(skip_serializing_if = "Option::is_none")]
15    pub dpop_bound_access_tokens_required: Option<bool>,
16}
17
18pub fn build_prm(
19    issuer: &str,
20    resource: &str,
21    scopes: &[String],
22    dpop_algs: Option<&[String]>,
23    dpop_required: bool,
24) -> ProtectedResourceMetadata {
25    // Three documented modes:
26    //   * Mode 1 — DPoP required: `dpop_algs = Some([..]), dpop_required = true`
27    //     ⇒ both fields emitted.
28    //   * Mode 2 — DPoP accepted: `dpop_algs = Some([..]), dpop_required = false`
29    //     ⇒ algs emitted, required=false emitted explicitly.
30    //   * Mode 3 — Bearer-only: `dpop_algs = None, dpop_required = false`
31    //     ⇒ both fields omitted from the PRM JSON.
32    //
33    // The original `dpop_algs.map(|_| dpop_required)` silently collapsed
34    // a `(None, true)` mis-call to `None` — a Bearer-permissive PRM
35    // emitted on a resource that the verifier was treating as
36    // DPoP-required. We surface the (None, true) case as an explicit
37    // `dpop_bound_access_tokens_required: true` so the misconfiguration
38    // is visible to clients even when the operator forgot to also
39    // declare an algorithm list. This diverges on the safer side from
40    // the prior gate-both-on-algs-present shape; aligning the rest of
41    // the surface is a separate follow-up.
42    let dpop_required_field = if dpop_algs.is_some() || dpop_required {
43        Some(dpop_required)
44    } else {
45        None
46    };
47
48    ProtectedResourceMetadata {
49        resource: resource.to_string(),
50        authorization_servers: vec![issuer.to_string()],
51        bearer_methods_supported: vec!["header".to_string()],
52        scopes_supported: scopes.to_vec(),
53        dpop_signing_alg_values_supported: dpop_algs.map(|items| items.to_vec()),
54        dpop_bound_access_tokens_required: dpop_required_field,
55    }
56}
57
58/// Require the resource identifier to be an absolute URL with both a
59/// scheme and a host, and free of the components RFC 8707 §2 forbids.
60///
61/// The scheme comes from RFC 8707 §2: the value "MUST be an absolute
62/// URI, as specified by Section 4.3 of [RFC3986]", whose grammar makes
63/// the scheme mandatory and excludes a fragment. The host comes from
64/// RFC 9728 §3, which derives the metadata document URL by inserting
65/// the well-known suffix after the host component — an identifier
66/// without a host gives that insertion nowhere to anchor and previously
67/// produced a malformed document URL instead of an error.
68///
69/// Every check runs on the **raw string**, before any parse, because
70/// the WHATWG parser behind `url::Url::parse` judges a *cleaned* value,
71/// not the identifier that is stored verbatim and served in the PRM
72/// `resource` member:
73///
74/// * it splits the fragment off, so a parse-based check never sees it;
75/// * it synthesizes an authority for a special scheme written without
76///   `//` (`https:example.com/mcp` parses with `host = example.com`,
77///   an identifier RFC 3986 gives no authority at all);
78/// * it strips ASCII tab/CR/LF anywhere in the input and trims leading
79///   C0-or-space before parsing, so a whitespace-bearing identifier
80///   would pass a parse-based gate while being stored with the
81///   whitespace.
82///
83/// Any such divergence between the stored identifier and the derived
84/// document URL is exactly the byte-for-byte mismatch RFC 9728 §3.3
85/// obliges a conformant client to discard. The checks run in a fixed
86/// order — fragment first — so an identifier wrong in more than one way
87/// reports deterministically.
88///
89/// The scheme is required but not narrowed: `http://localhost:8080/mcp`
90/// stays accepted as a deliberate profile relaxation so local
91/// development loops keep working — this gate is not an https-only
92/// check.
93pub(crate) fn validate_resource_identifier(resource: &str) -> Result<(), AuthplaneError> {
94    // The rejected value is not echoed in any of these messages: a
95    // malformed identifier can carry userinfo (`//user:pass@host/path`),
96    // and the messages reach startup logs.
97    let reject = |message| auth_error("invalid_resource", message);
98
99    // Fragment first — RFC 8707 §2 forbids it outright ("The URI MUST
100    // NOT include a fragment component"; RFC 9728 §1.2 repeats it for
101    // the resource identifier). Checked on the raw string because
102    // `Url::parse` splits the fragment off.
103    if resource.contains('#') {
104        return Err(reject(
105            "resource identifier must not include a fragment component (RFC 8707 section 2; RFC 9728 section 1.2)",
106        ));
107    }
108
109    // Whitespace or control characters anywhere in the identifier: the
110    // parser would strip or trim them and validate the cleaned string,
111    // while the identifier is stored and advertised with them intact.
112    if resource
113        .chars()
114        .any(|c| c.is_ascii_whitespace() || c.is_ascii_control())
115    {
116        return Err(reject(
117            "resource identifier must not contain whitespace or control characters",
118        ));
119    }
120
121    // Anchored `scheme://authority` shape on the raw string. This is
122    // what rejects a relative reference (`/mcp`), a scheme-relative one
123    // (`//api.example.com/mcp`), an opaque identifier (`urn:example:api`
124    // — no authority for RFC 9728 §3 to anchor the well-known suffix
125    // to), and the authority-less special-scheme form
126    // (`https:example.com/mcp`) the parser would silently repair.
127    let Some(authority) = explicit_authority(resource) else {
128        return Err(reject(
129            "resource identifier must be an absolute URL with a scheme and a host",
130        ));
131    };
132
133    // Userinfo — RFC 9110 section 4.2.4 deprecates it and tells
134    // recipients to treat its presence as an error; letting it through
135    // would carry the credential verbatim into the PRM `resource`
136    // member served to unauthenticated callers.
137    if authority.contains('@') {
138        return Err(reject(
139            "resource identifier must not include userinfo in its authority (RFC 9110 section 4.2.4)",
140        ));
141    }
142
143    // Defensive backstop: the identifier must still parse and expose a
144    // host. This catches what the raw-string shape cannot express, e.g.
145    // an empty authority (`foo:///path`).
146    let parsed = url::Url::parse(resource).map_err(|_| {
147        reject("resource identifier must be an absolute URL with a scheme and a host")
148    })?;
149    if !parsed.has_host() {
150        return Err(reject(
151            "resource identifier must be an absolute URL with a scheme and a host",
152        ));
153    }
154    Ok(())
155}
156
157/// Split off the authority component of an identifier written in the
158/// explicit `scheme://authority[/path][?query]` form. Returns `None`
159/// when the `://` marker is missing or the scheme violates the RFC 3986
160/// §3.1 grammar (`ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )`).
161fn explicit_authority(resource: &str) -> Option<&str> {
162    let (scheme, rest) = resource.split_once("://")?;
163    let mut scheme_chars = scheme.chars();
164    if !scheme_chars.next()?.is_ascii_alphabetic() {
165        return None;
166    }
167    if !scheme_chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) {
168        return None;
169    }
170    let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
171    Some(&rest[..authority_end])
172}
173
174/// RFC 9728 §3 — derive the well-known PRM document URL from the
175/// resource identifier by inserting the suffix "between the host
176/// component and the path and/or query components, if any". The
177/// identifier's query, when present, survives into the derived URL:
178/// RFC 8707 §2 states the SHOULD NOT and its scoping exception in the
179/// same sentence, and RFC 9728 §1.2 carries that carve-out forward, so
180/// two identifiers differing only by query must not collapse onto one
181/// document URL. A bare trailing `?` (empty query) is the exception:
182/// it identifies nothing and derives the query-less URL.
183pub fn build_prm_url(resource: &str) -> Result<String, AuthplaneError> {
184    // Defensive backstop for direct callers. The authoritative gate runs
185    // at construction time in `AuthplaneResource::from_parts` /
186    // `from_prefetched_metadata`, so a resource that exists at all can
187    // always derive its document URL.
188    validate_resource_identifier(resource)?;
189    build_well_known_url(
190        resource,
191        "oauth-protected-resource",
192        QueryComponent::Preserve,
193        "invalid_resource",
194        || format!("invalid resource URL: {resource}"),
195    )
196}
197
198#[cfg(test)]
199mod tests {
200    use super::{build_prm, build_prm_url};
201
202    #[test]
203    fn prm_builder_keeps_expected_fields() {
204        let prm = build_prm(
205            "https://auth.example.com",
206            "https://api.example.com/mcp",
207            &["tools/read".to_string()],
208            Some(&["ES256".to_string()]),
209            true,
210        );
211
212        assert_eq!(prm.resource, "https://api.example.com/mcp");
213        assert_eq!(
214            prm.authorization_servers,
215            vec!["https://auth.example.com".to_string()]
216        );
217        assert_eq!(prm.bearer_methods_supported, vec!["header".to_string()]);
218        assert_eq!(
219            prm.dpop_signing_alg_values_supported,
220            Some(vec!["ES256".to_string()])
221        );
222        assert_eq!(prm.dpop_bound_access_tokens_required, Some(true));
223    }
224
225    #[test]
226    fn prm_url_inserts_well_known_before_path() {
227        let url = build_prm_url("https://api.example.com/v1/mcp").expect("valid prm url");
228        assert_eq!(
229            url,
230            "https://api.example.com/.well-known/oauth-protected-resource/v1/mcp"
231        );
232    }
233
234    #[test]
235    fn prm_url_preserves_resource_query() {
236        // RFC 9728 §3: the suffix goes between the host and "the path
237        // and/or query components, if any" — the query is part of the
238        // derived document URL.
239        let url = build_prm_url("https://api.example.com/mcp?tenant=a").expect("valid prm url");
240        assert_eq!(
241            url,
242            "https://api.example.com/.well-known/oauth-protected-resource/mcp?tenant=a"
243        );
244    }
245
246    #[test]
247    fn prm_url_query_only_resource_appends_suffix_directly_after_host() {
248        // No path and no terminating slash: §3.1 has no slash to remove,
249        // the suffix follows the host directly, and the query follows it.
250        let url = build_prm_url("https://api.example.com?x=1").expect("valid prm url");
251        assert_eq!(
252            url,
253            "https://api.example.com/.well-known/oauth-protected-resource?x=1"
254        );
255    }
256
257    #[test]
258    fn prm_url_removes_terminating_slash_before_query() {
259        // §3.1 removes the terminating "/", so this derives the same URL
260        // as the slash-less form above.
261        let url = build_prm_url("https://api.example.com/?x=1").expect("valid prm url");
262        assert_eq!(
263            url,
264            "https://api.example.com/.well-known/oauth-protected-resource?x=1"
265        );
266    }
267
268    #[test]
269    fn prm_url_treats_bare_trailing_question_mark_as_no_query() {
270        // An empty query identifies nothing: `?` alone resolves to the
271        // same document URL as the query-less identifier, not to a URL
272        // ending in a lone `?` that no client would re-derive.
273        let url = build_prm_url("https://api.example.com/mcp?").expect("valid prm url");
274        assert_eq!(
275            url,
276            "https://api.example.com/.well-known/oauth-protected-resource/mcp"
277        );
278    }
279
280    #[test]
281    fn prm_url_removes_terminating_slash_from_path() {
282        // §3.1 removes the terminating "/" from a non-root path too —
283        // the trailing-slash and slash-less forms derive one document.
284        let url = build_prm_url("https://api.example.com/mcp/").expect("valid prm url");
285        assert_eq!(
286            url,
287            "https://api.example.com/.well-known/oauth-protected-resource/mcp"
288        );
289    }
290
291    #[test]
292    fn prm_url_removes_all_terminating_slashes_from_path() {
293        // Only *trailing* slashes come off; doubled ones at the end are
294        // normalization noise, unlike the leading `//mcp` case below.
295        let url = build_prm_url("https://api.example.com/mcp//").expect("valid prm url");
296        assert_eq!(
297            url,
298            "https://api.example.com/.well-known/oauth-protected-resource/mcp"
299        );
300    }
301
302    #[test]
303    fn prm_url_keeps_query_distinct_identifiers_distinct() {
304        // Two identifiers differing only by query must not collapse onto
305        // one metadata document URL (the multi-tenant case RFC 8707 §2
306        // names as the reason a query can be necessary).
307        let tenant_a = build_prm_url("https://api.example.com/mcp?tenant=a").expect("valid");
308        let tenant_b = build_prm_url("https://api.example.com/mcp?tenant=b").expect("valid");
309        assert_ne!(tenant_a, tenant_b);
310    }
311
312    const ABSOLUTE_URL_MESSAGE: &str =
313        "resource identifier must be an absolute URL with a scheme and a host";
314    const FRAGMENT_MESSAGE: &str = "resource identifier must not include a fragment component";
315    const WHITESPACE_MESSAGE: &str =
316        "resource identifier must not contain whitespace or control characters";
317    const USERINFO_MESSAGE: &str = "resource identifier must not include userinfo";
318
319    fn assert_rejects_as_invalid_resource(resource: &str, expected_message: &str) {
320        let error = build_prm_url(resource).expect_err("resource must be rejected");
321        let crate::AuthplaneError::Auth(auth_error) = error else {
322            panic!("expected auth error");
323        };
324        assert_eq!(auth_error.code, "invalid_resource");
325        assert!(
326            auth_error.message.contains(expected_message),
327            "unexpected message: {}",
328            auth_error.message
329        );
330    }
331
332    #[test]
333    fn prm_url_rejects_relative_resource() {
334        // RFC 8707 §2: the value MUST be an absolute URI (RFC 3986 §4.3),
335        // so a relative reference has no scheme to satisfy the grammar.
336        assert_rejects_as_invalid_resource("/mcp", ABSOLUTE_URL_MESSAGE);
337    }
338
339    #[test]
340    fn prm_url_rejects_scheme_relative_resource() {
341        // A scheme-relative reference carries an authority, so a guard
342        // asking only "opaque or authority-less?" would admit it — the
343        // missing component is the scheme, and it must reject on its
344        // own.
345        assert_rejects_as_invalid_resource("//api.example.com/mcp", ABSOLUTE_URL_MESSAGE);
346    }
347
348    #[test]
349    fn prm_url_rejects_opaque_resource_without_host() {
350        // No authority at all, so RFC 9728 §3 has no insertion point for
351        // the well-known suffix. Previously this derived a garbled
352        // document URL instead of erroring.
353        assert_rejects_as_invalid_resource("urn:example:api", ABSOLUTE_URL_MESSAGE);
354    }
355
356    #[test]
357    fn prm_url_rejects_fragment_bearing_resource() {
358        // RFC 8707 §2: "The URI MUST NOT include a fragment component";
359        // RFC 9728 §1.2 repeats it. The WHATWG parser splits the
360        // fragment off, so this must be caught on the raw string — a
361        // parse-based gate accepted it while `build_well_known_url`
362        // stripped the fragment from the derived document URL, leaving
363        // the stored identifier and the document URL to disagree
364        // byte-for-byte.
365        assert_rejects_as_invalid_resource("https://api.example.com/mcp#v2", FRAGMENT_MESSAGE);
366    }
367
368    #[test]
369    fn prm_url_reports_fragment_first_on_doubly_invalid_resource() {
370        // Wrong in two ways (no scheme, fragment present): the fragment
371        // check runs first, so the report is deterministic.
372        assert_rejects_as_invalid_resource("//api.example.com/mcp#v2", FRAGMENT_MESSAGE);
373    }
374
375    #[test]
376    fn prm_url_rejects_authority_less_special_scheme_form() {
377        // RFC 3986 gives `https:example.com/mcp` no authority
378        // (`hier-part = path-rootless`), but the WHATWG parser
379        // synthesizes one: `Url::parse` yields `host = example.com` and
380        // serializes as `https://example.com/mcp`. A parse-based gate
381        // therefore accepted an identifier its own message claims to
382        // reject, and the stored-verbatim identifier differed from the
383        // derived document URL. The anchored `scheme://` check on the
384        // raw string closes this.
385        assert_rejects_as_invalid_resource("https:example.com/mcp", ABSOLUTE_URL_MESSAGE);
386    }
387
388    #[test]
389    fn prm_url_rejects_whitespace_in_resource() {
390        // The WHATWG parser strips ASCII tab/CR/LF anywhere in the input
391        // (`https://api.exa\tmple.com/mcp` parses to
392        // `https://api.example.com/mcp`) and trims leading C0-or-space,
393        // so a parse-based gate accepted identifiers that are stored and
394        // advertised with the whitespace intact.
395        assert_rejects_as_invalid_resource("https://api.exa\tmple.com/mcp", WHITESPACE_MESSAGE);
396        assert_rejects_as_invalid_resource(" https://api.example.com/mcp", WHITESPACE_MESSAGE);
397    }
398
399    #[test]
400    fn prm_url_rejects_userinfo_in_authority() {
401        // RFC 9110 §4.2.4 deprecates userinfo and tells recipients to
402        // treat its presence as an error. The `url` crate preserves
403        // userinfo across `set_path` + `to_string`, so before this gate
404        // the credential reached both the derived document URL and —
405        // verbatim — the `resource` member of the PRM document served to
406        // unauthenticated callers.
407        assert_rejects_as_invalid_resource("https://svc:pw@api.example.com/mcp", USERINFO_MESSAGE);
408    }
409
410    #[test]
411    fn prm_url_rejects_empty_authority() {
412        // Passes the `scheme://` shape check but parses with no host —
413        // the defensive `has_host` backstop still rejects it.
414        assert_rejects_as_invalid_resource("foo:///mcp", ABSOLUTE_URL_MESSAGE);
415    }
416
417    #[test]
418    fn prm_url_accepts_http_localhost() {
419        // Scheme and host are required but the scheme is not narrowed:
420        // plain-http local development hosts stay accepted.
421        let url = build_prm_url("http://localhost:8080/mcp").expect("http localhost accepted");
422        assert_eq!(
423            url,
424            "http://localhost:8080/.well-known/oauth-protected-resource/mcp"
425        );
426    }
427
428    #[test]
429    fn prm_url_keeps_doubled_leading_slash_distinct() {
430        // §3.1 only removes the *terminating* slash; `//mcp` and `/mcp`
431        // are distinct identifiers and must derive distinct documents.
432        let doubled = build_prm_url("https://api.example.com//mcp").expect("valid prm url");
433        let single = build_prm_url("https://api.example.com/mcp").expect("valid prm url");
434        assert_eq!(
435            doubled,
436            "https://api.example.com/.well-known/oauth-protected-resource//mcp"
437        );
438        assert_ne!(doubled, single);
439    }
440
441    /// Regression: `dpop_bound_access_tokens_required` MUST NOT depend on
442    /// `dpop_algs` being `Some`. The previous `dpop_algs.map(|_| required)`
443    /// silently dropped the `required = true` flag when the caller passed
444    /// no algorithms — the PRM then advertised neither algorithms nor a
445    /// requirement, leaving a Bearer-permissive document on a
446    /// DPoP-required resource. The (None, true) case is now emitted
447    /// explicitly so the misconfiguration is visible to clients.
448    #[test]
449    fn prm_required_flag_survives_when_no_algs_given() {
450        let prm = build_prm(
451            "https://auth.example.com",
452            "https://api.example.com/mcp",
453            &[],
454            None,
455            true,
456        );
457        assert_eq!(prm.dpop_signing_alg_values_supported, None);
458        assert_eq!(prm.dpop_bound_access_tokens_required, Some(true));
459    }
460
461    /// Mode 3 — Bearer-only resource omits both DPoP fields from the
462    /// PRM JSON. This stays the same as the original behaviour and is
463    /// covered by `rfc9728_prm_must_advertise_dpop_required_when_resource_requires_dpop`
464    /// in the conformance suite; the regression here is just locking in
465    /// the local invariant alongside the Mode-1 fix above.
466    #[test]
467    fn prm_omits_both_fields_for_bearer_only_mode() {
468        let prm = build_prm(
469            "https://auth.example.com",
470            "https://api.example.com/mcp",
471            &[],
472            None,
473            false,
474        );
475        assert_eq!(prm.dpop_signing_alg_values_supported, None);
476        assert_eq!(prm.dpop_bound_access_tokens_required, None);
477    }
478}