Skip to main content

oauth_as/
resource_metadata.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (C) 2026 Matthew Jackson
3
4//! RFC 9728 protected resource metadata: the document served at
5//! `{resource}/.well-known/oauth-protected-resource`.
6//!
7//! # Read this before using the module: whose document is this
8//!
9//! THIS CRATE IS AN AUTHORIZATION SERVER. RFC 9728 defines a document a PROTECTED RESOURCE (a
10//! resource server) publishes about itself, and section 3.1 places it under the RESOURCE's own
11//! identifier, not under the AS's issuer. So the boundary this module draws is deliberate and it
12//! is not a limitation to be papered over later:
13//!
14//! - [`ProtectedResourceMetadata`] is the TYPE. A host that runs a resource server (very often the
15//!   same process that embeds this AS, which is why the type lives here at all) fills it in from
16//!   [`ProtectedResourceConfig`] and serves it from its own resource origin. This crate does not
17//!   serve it, does not route it, and does not validate incoming access tokens: none of those is
18//!   an authorization server's job, and pretending otherwise would be the "true but substantively
19//!   misleading" trap that this project already refuses for OIDC bolt-ons.
20//! - The AUTHORIZATION SERVER half of RFC 9728 is section 4 alone: the `protected_resources`
21//!   member on the RFC 8414 document, which this crate does derive, from
22//!   [`crate::server::ServerConfig::protected_resources`]. That member is the AS's own statement
23//!   about which resources it issues tokens for, and section 7.6 is why it is worth publishing: an
24//!   `authorization_servers` entry in a resource's document is a claim made BY THE RESOURCE, and a
25//!   client that believes it unchecked can be pointed at an AS that never heard of that resource.
26//!
27//! # Why the type is derived from config rather than hand-written
28//!
29//! Exactly the reasoning [`crate::metadata::AuthorizationServerMetadata::from_config`] gives: this
30//! document is read by clients before they talk to the resource, so an advertised capability the
31//! resource does not have is a lie the client cannot recover from. Every member below is either
32//! REQUIRED by section 2 or derived from something the host actually declared, and an optional
33//! member the host did not declare is OMITTED rather than serialized as `null`. Section 2 defines
34//! member types and `null` is not one of them.
35
36use serde::{Deserialize, Serialize};
37
38/// The well-known URI suffix RFC 9728 section 3.1 registers for this document.
39///
40/// This is the BARE form, correct only for a resource identifier with no path or query. Use
41/// [`well_known_path`] to place the document for a given resource: as in RFC 8414 section 3.1, the
42/// suffix is INSERTED between the host and the rest of the identifier rather than appended to it.
43pub const PROTECTED_RESOURCE_WELL_KNOWN_PATH: &str = "/.well-known/oauth-protected-resource";
44
45/// The part of a resource identifier that RFC 9728 section 3.1 places AFTER the well-known suffix:
46/// `""` for `https://rs.example`, `"/api"` for `https://rs.example/api`.
47///
48/// The path case is delegated to [`crate::metadata::issuer_path`] rather than parsed again here.
49/// RFC 9728 section 3.1 is the same insertion rule RFC 8414 section 3.1 states, and two
50/// hand-rolled parsers for one rule is exactly how the AS document and the resource document end
51/// up disagreeing about where a tenant lives.
52///
53/// One shape is handled on top of it, because it is a shape an RFC 8414 issuer cannot have:
54/// section 3.1 says "path and/or query components", so a resource identifier MAY carry a query
55/// with no path at all (`https://rs.example?tenant=1`). `issuer_path` splits at the first `/`, so
56/// it reports nothing for that, and the query would otherwise be silently dropped from the URL a
57/// client is told to fetch.
58pub fn resource_path(resource: &str) -> &str {
59    let path = crate::metadata::issuer_path(resource);
60    if !path.is_empty() {
61        // "any terminating slash (/) following the host component MUST be removed" (section 3.1).
62        // `issuer_path` already trims a trailing one; this is the same slash carrying a query, as
63        // in `https://rs.example/?tenant=1`, which would otherwise place the document at
64        // `/.well-known/oauth-protected-resource/?tenant=1`.
65        if path.starts_with("/?") {
66            return &path[1..];
67        }
68        return path;
69    }
70    // A `?` cannot appear in a scheme or an authority, so the first one in the whole string is the
71    // start of the query and no second authority scan is needed.
72    match resource.find('?') {
73        Some(i) => &resource[i..],
74        None => "",
75    }
76}
77
78/// Where this document lives for `resource`, as an absolute path from the origin's root.
79///
80/// RFC 9728 section 3.1: the well-known string goes BETWEEN the host and the rest of the resource
81/// identifier. For resource `https://rs.example/api` the document is at
82/// `https://rs.example/.well-known/oauth-protected-resource/api`, NOT at
83/// `https://rs.example/api/.well-known/...` and NOT at the bare well-known path.
84///
85/// This is the same trap RFC 8414 section 3.1 sets for the AS document, and it matters for the
86/// same two reasons: section 3.3 makes the client compare the `resource` member against the
87/// identifier it inserted the suffix into, so a document served where that check cannot pass
88/// teaches clients to skip a check that exists to stop a resource impersonating another; and a
89/// deployment with several resources on one origin would otherwise collide on one bare path.
90pub fn well_known_path(resource: &str) -> String {
91    let path = resource_path(resource);
92    let mut out = String::with_capacity(PROTECTED_RESOURCE_WELL_KNOWN_PATH.len() + path.len());
93    out.push_str(PROTECTED_RESOURCE_WELL_KNOWN_PATH);
94    out.push_str(path);
95    out
96}
97
98/// How a client may present a bearer token to this resource (RFC 6750 sections 2.1, 2.2 and 2.3),
99/// which RFC 9728 section 2 publishes as `bearer_methods_supported`.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
101#[serde(rename_all = "snake_case")]
102pub enum BearerMethod {
103    /// RFC 6750 section 2.1, the `Authorization: Bearer` request header field. The only method
104    /// OAuth 2.1 keeps, and the only one [`ProtectedResourceConfig::new`] advertises by default.
105    Header,
106    /// RFC 6750 section 2.2, the `access_token` form-encoded body parameter.
107    Body,
108    /// RFC 6750 section 2.3, the `access_token` query parameter. RFC 6750 itself says SHOULD NOT,
109    /// because a URI carrying a credential is logged by every proxy on the path and leaks through
110    /// `Referer`. A host that advertises this is stating a fact about its own resource, not being
111    /// given a recommendation.
112    Query,
113}
114
115/// What a host declares about its own protected resource, from which
116/// [`ProtectedResourceMetadata::from_config`] derives the document.
117///
118/// Only two things have no sane default and are therefore arguments to
119/// [`ProtectedResourceConfig::new`]: the resource identifier (section 2 makes `resource`
120/// REQUIRED), and the issuer identifier of at least one authorization server, without which the
121/// document tells a client nothing it can act on.
122#[derive(Debug, Clone, PartialEq, Eq)]
123/// `#[non_exhaustive]`: RFC 9728 section 7.1 registers these members in an IANA registry that takes
124/// new entries, and this type gains a field for each one this crate learns to publish. A host that
125/// wrote a full struct literal would have a build that breaks on a PATCH release that only added a
126/// member. Construct with `new()` and assign the fields you want. This is the one attribute on this
127/// type that cannot be added after publication, because by then somebody's struct literal is in
128/// production.
129///
130/// Note that the justification is NOT the one
131/// [`crate::metadata::AuthorizationServerMetadata`] carries: that type's field set genuinely varies
132/// with cargo features, and this one's does not — there is no `#[cfg]` on any field here, because
133/// RFC 9728 is the whole of the `resource-metadata` feature and nothing else gates a member of it.
134/// Both types want the attribute; they want it for different reasons, and stating the wrong one
135/// invites somebody to remove the attribute on discovering the reason is untrue.
136#[non_exhaustive]
137pub struct ProtectedResourceConfig {
138    /// Section 2 `resource`: the resource identifier, an absolute URI with no fragment. This is
139    /// the SAME string a client sends as an RFC 8707 `resource` indicator to get a token for this
140    /// resource, and the same string section 3.3 requires the served document to echo.
141    pub resource: String,
142    /// Section 2 `authorization_servers`: the issuer identifiers of the ASes that can issue tokens
143    /// for this resource. Empty omits the member rather than publishing an empty array, which
144    /// would say "no authorization server can issue for me".
145    pub authorization_servers: Vec<String>,
146    /// Section 2 `jwks_uri`: the RESOURCE's own key set, for signed resource responses. This is
147    /// NOT the authorization server's `jwks_uri` and must not be set to it: section 2 defines it
148    /// as the keys a client uses to validate signatures FROM this resource, and pointing it at the
149    /// AS would tell clients to validate resource responses with token-signing keys.
150    pub jwks_uri: Option<String>,
151    /// Section 2 `scopes_supported` (RECOMMENDED): the scope values used with this resource.
152    /// `None` omits the member; an empty catalogue and an undeclared one are different claims.
153    pub scopes_supported: Option<Vec<String>>,
154    /// Section 2 `bearer_methods_supported`. Empty omits the member, which section 2 leaves as
155    /// "unspecified" rather than "none".
156    pub bearer_methods_supported: Vec<BearerMethod>,
157    /// Section 2 `resource_signing_alg_values_supported`: JWS `alg` values this resource signs its
158    /// RESPONSES with. Empty omits the member. `none` is not a value this crate will emit, for the
159    /// reason RFC 7518 section 3.6 gives.
160    pub resource_signing_alg_values_supported: Vec<String>,
161    /// Section 2.1 `resource_name` (RECOMMENDED): a human-readable name for display to end users.
162    pub resource_name: Option<String>,
163    /// Section 2 `resource_documentation`: a page of developer documentation.
164    pub resource_documentation: Option<String>,
165    /// Section 2 `resource_policy_uri`: how the resource's data is used.
166    pub resource_policy_uri: Option<String>,
167    /// Section 2 `resource_tos_uri`: terms of service.
168    pub resource_tos_uri: Option<String>,
169    /// Section 2 `tls_client_certificate_bound_access_tokens` (RFC 8705): whether this resource
170    /// supports mutual-TLS certificate-bound access tokens. `false` OMITS the member rather than
171    /// publishing `false`, because section 2 gives `false` as the default when absent and a host
172    /// that never thought about mTLS should not be made to publish a sentence about it.
173    pub tls_client_certificate_bound_access_tokens: bool,
174    /// Section 2 `dpop_bound_access_tokens_required` (RFC 9449): whether this resource ALWAYS
175    /// requires DPoP-bound tokens. Same omit-on-false rule and same reason.
176    pub dpop_bound_access_tokens_required: bool,
177    /// Section 2 `dpop_signing_alg_values_supported`: JWS `alg` values accepted in a DPoP proof.
178    /// Empty omits the member.
179    pub dpop_signing_alg_values_supported: Vec<String>,
180    /// Section 2 `authorization_details_types_supported` (RFC 9396): the RAR type values this
181    /// resource understands. Empty omits the member.
182    pub authorization_details_types_supported: Vec<String>,
183    /// Section 2.2 `signed_metadata`: a JWT whose claims are these same members, signed by the
184    /// resource.
185    ///
186    /// This crate does NOT produce it, and will not silently: signing requires a key this crate
187    /// does not hold (the RESOURCE's key, not the AS's), and section 7.9 makes the signed and
188    /// unsigned documents carry different trust, so manufacturing one here would be the AS
189    /// asserting something about a resource on the resource's behalf. A host that signs its own
190    /// document sets the compact serialization here and this crate passes it through unread.
191    pub signed_metadata: Option<String>,
192}
193
194impl ProtectedResourceConfig {
195    /// A config for `resource`, protected by the authorization server at issuer identifier
196    /// `authorization_server`.
197    ///
198    /// Defaults chosen so that the document a host publishes without touching anything else is
199    /// both minimal and true: header-only bearer presentation (RFC 6750 section 2.1, the one form
200    /// OAuth 2.1 keeps), and no capability claimed that the host has not stated.
201    pub fn new(resource: impl Into<String>, authorization_server: impl Into<String>) -> Self {
202        ProtectedResourceConfig {
203            resource: resource.into(),
204            authorization_servers: vec![authorization_server.into()],
205            jwks_uri: None,
206            scopes_supported: None,
207            bearer_methods_supported: vec![BearerMethod::Header],
208            resource_signing_alg_values_supported: Vec::new(),
209            resource_name: None,
210            resource_documentation: None,
211            resource_policy_uri: None,
212            resource_tos_uri: None,
213            tls_client_certificate_bound_access_tokens: false,
214            dpop_bound_access_tokens_required: false,
215            dpop_signing_alg_values_supported: Vec::new(),
216            authorization_details_types_supported: Vec::new(),
217            signed_metadata: None,
218        }
219    }
220}
221
222/// An RFC 9728 protected resource metadata document.
223///
224/// Published by the RESOURCE, at [`well_known_path`] under the resource's own origin. See the
225/// module docs for why this crate carries the type but does not serve it.
226///
227/// Optional members are `Option` and are OMITTED when absent, never serialized as `null`, exactly
228/// as [`crate::metadata::AuthorizationServerMetadata`] does and for the same reason: section 2
229/// defines member types, and `null` is not one of them.
230///
231/// `#[non_exhaustive]` for the same reason [`ProtectedResourceConfig`] is, and it is the DOCUMENT
232/// that the reason is really about: RFC 9728 section 7.1 registers its members in an IANA registry
233/// that takes new entries, so this type gains a field whenever the crate learns to publish one, and
234/// a member added to a wire format is not a breaking change to anybody except a host who wrote the
235/// struct out by hand. The supported way to build one is
236/// [`ProtectedResourceMetadata::from_config`], which is also the only way to get a document that
237/// agrees with the [`ProtectedResourceConfig`] the host actually declared. `Deserialize` is derived
238/// and is unaffected, so a client-side or test-side consumer parsing a served document still works,
239/// and so does reading or matching on any field.
240///
241/// Added in 0.9.1, which is the last release it can be added in: 0.9.0 was an alpha published so
242/// the crate could be built against, and after a release meant for real use the attribute can never
243/// go on, because by then somebody's struct literal is in production.
244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
245#[non_exhaustive]
246pub struct ProtectedResourceMetadata {
247    /// REQUIRED (section 2). Section 3.3 makes this the member a client checks: it MUST be
248    /// identical to the resource identifier the well-known suffix was inserted into, or the
249    /// document MUST NOT be used.
250    pub resource: String,
251    /// OPTIONAL (section 2). Issuer identifiers, each of which a client then discovers through RFC
252    /// 8414. Section 7.6: this is the RESOURCE's claim, so a client is expected to be suspicious
253    /// of it rather than to follow it blindly.
254    #[serde(skip_serializing_if = "Option::is_none")]
255    pub authorization_servers: Option<Vec<String>>,
256    /// OPTIONAL (section 2). The RESOURCE's key set, not the AS's.
257    #[serde(skip_serializing_if = "Option::is_none")]
258    pub jwks_uri: Option<String>,
259    /// RECOMMENDED (section 2). Section 7.2: publishing scopes is what lets a client ask for the
260    /// least it needs rather than the most it can.
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub scopes_supported: Option<Vec<String>>,
263    /// OPTIONAL (section 2).
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub bearer_methods_supported: Option<Vec<BearerMethod>>,
266    /// OPTIONAL (section 2). JWS `alg` values for signed responses FROM this resource.
267    #[serde(skip_serializing_if = "Option::is_none")]
268    pub resource_signing_alg_values_supported: Option<Vec<String>>,
269    /// RECOMMENDED (section 2.1). Human-readable, for display to end users.
270    #[serde(skip_serializing_if = "Option::is_none")]
271    pub resource_name: Option<String>,
272    /// OPTIONAL (section 2).
273    #[serde(skip_serializing_if = "Option::is_none")]
274    pub resource_documentation: Option<String>,
275    /// OPTIONAL (section 2).
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub resource_policy_uri: Option<String>,
278    /// OPTIONAL (section 2).
279    #[serde(skip_serializing_if = "Option::is_none")]
280    pub resource_tos_uri: Option<String>,
281    /// OPTIONAL (section 2), RFC 8705. Omitted rather than `false`, since section 2 already gives
282    /// `false` as the default when the member is absent.
283    #[serde(skip_serializing_if = "Option::is_none")]
284    pub tls_client_certificate_bound_access_tokens: Option<bool>,
285    /// OPTIONAL (section 2), RFC 9396.
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub authorization_details_types_supported: Option<Vec<String>>,
288    /// OPTIONAL (section 2), RFC 9449.
289    #[serde(skip_serializing_if = "Option::is_none")]
290    pub dpop_signing_alg_values_supported: Option<Vec<String>>,
291    /// OPTIONAL (section 2), RFC 9449. Omitted rather than `false`, as above.
292    #[serde(skip_serializing_if = "Option::is_none")]
293    pub dpop_bound_access_tokens_required: Option<bool>,
294    /// OPTIONAL (section 2.2). Passed through from the host; never produced by this crate.
295    #[serde(skip_serializing_if = "Option::is_none")]
296    pub signed_metadata: Option<String>,
297}
298
299/// `None` for an empty list, so an undeclared capability is an omitted member rather than an empty
300/// array. The distinction is the whole of the omission rule: `[]` is a claim ("I support none of
301/// these"), absence is silence, and section 2 gives a default for several of these members that
302/// only applies when they are absent.
303fn some_unless_empty<T>(values: Vec<T>) -> Option<Vec<T>> {
304    (!values.is_empty()).then_some(values)
305}
306
307impl ProtectedResourceMetadata {
308    /// Derive the document from the resource's configuration.
309    pub fn from_config(config: &ProtectedResourceConfig) -> Self {
310        ProtectedResourceMetadata {
311            // Section 3.3 compares this for equality against the identifier the client built the
312            // request URL from, so a trailing slash is trimmed here exactly as
313            // `AuthorizationServerMetadata::from_config` trims the issuer's: a host that wrote
314            // `https://rs.example/api/` must not end up with two spellings of one identity, and
315            // `well_known_path` trims the same slash when placing the document.
316            resource: config.resource.trim_end_matches('/').to_string(),
317            authorization_servers: some_unless_empty(config.authorization_servers.clone()),
318            jwks_uri: config.jwks_uri.clone(),
319            scopes_supported: config.scopes_supported.clone(),
320            bearer_methods_supported: some_unless_empty(config.bearer_methods_supported.clone()),
321            resource_signing_alg_values_supported: some_unless_empty(
322                config.resource_signing_alg_values_supported.clone(),
323            ),
324            resource_name: config.resource_name.clone(),
325            resource_documentation: config.resource_documentation.clone(),
326            resource_policy_uri: config.resource_policy_uri.clone(),
327            resource_tos_uri: config.resource_tos_uri.clone(),
328            tls_client_certificate_bound_access_tokens: config
329                .tls_client_certificate_bound_access_tokens
330                .then_some(true),
331            authorization_details_types_supported: some_unless_empty(
332                config.authorization_details_types_supported.clone(),
333            ),
334            dpop_signing_alg_values_supported: some_unless_empty(
335                config.dpop_signing_alg_values_supported.clone(),
336            ),
337            dpop_bound_access_tokens_required: config
338                .dpop_bound_access_tokens_required
339                .then_some(true),
340            signed_metadata: config.signed_metadata.clone(),
341        }
342    }
343
344    /// Where this document belongs, as an absolute path from the resource origin's root. See
345    /// [`well_known_path`], which this defers to so the served location and the `resource` member
346    /// cannot drift apart.
347    pub fn well_known_path(&self) -> String {
348        well_known_path(&self.resource)
349    }
350}
351
352#[cfg(test)]
353#[path = "tests/resource_metadata.rs"]
354mod tests;