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}