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;