1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
//! Validator configuration metadata.
use serde::Serialize;
/// Metadata about how an access token validator is configured.
///
/// Returned by [`ProvideValidatorMetadata::validator_metadata`]. Intended as
/// input to a Protected Resource Metadata document (RFC 9728).
#[derive(Debug, Clone, Serialize)]
pub struct ValidatorMetadata {
/// The realm identifying the protection space (RFC 6750 §3).
///
/// Included as `realm="..."` in `WWW-Authenticate` challenges when set.
#[serde(skip_serializing_if = "Option::is_none")]
pub realm: Option<String>,
/// The authorization server(s) this validator trusts, by issuer URI.
///
/// `None` if not known or if the authorization server does not have an issuer URI.
#[serde(skip_serializing_if = "Option::is_none")]
pub authorization_servers: Option<Vec<String>>,
/// DPoP proof signing algorithms accepted by this validator.
///
/// `None` if unrestricted (the validator accepts any algorithm its verifier supports).
/// When `None`, this field should be omitted from RFC 9728 metadata.
#[serde(skip_serializing_if = "Option::is_none")]
pub dpop_signing_alg_values_supported: Option<Vec<String>>,
/// Whether DPoP-bound tokens are required.
///
/// `Some(true)` means Bearer tokens are rejected. `None` means the requirement
/// status is not known (e.g. a validator that cannot determine this). Maps to
/// `dpop_bound_access_tokens_required` in RFC 9728 metadata.
#[serde(skip_serializing_if = "Option::is_none")]
pub dpop_bound_access_tokens_required: Option<bool>,
/// The resource server's identifier URI.
///
/// Provided by the caller to identify this specific resource instance. Maps to `resource` in
/// RFC 9728 metadata.
#[serde(skip_serializing_if = "Option::is_none")]
pub resource: Option<String>,
/// Supported methods for presenting Bearer tokens (RFC 6750).
///
/// Values correspond to RFC 6750 sections: `"header"` (§2.1 Authorization header),
/// `"body"` (§2.2 form-encoded body parameter), `"query"` (§2.3 URI query parameter).
/// `None` means unspecified. Maps to `bearer_methods_supported` in RFC 9728 metadata.
#[serde(skip_serializing_if = "Option::is_none")]
pub bearer_methods_supported: Option<Vec<&'static str>>,
}
use crate::TokenType;
use crate::error::{ToRfc6750Error, TokenValidationError, escape_quoted};
impl ValidatorMetadata {
/// Returns the `WWW-Authenticate` challenges for a request.
///
/// If `error` is `None`, returns unauthenticated challenges for all supported
/// schemes. If `error` is `Some` and the error is a [`TokenValidationError::Client`],
/// the attempted scheme's challenge includes error details; other schemes are
/// returned as unauthenticated challenges. If the attempted scheme is ambiguous,
/// both challenges include error details, as permitted by RFC 9449 §7.1.
///
/// If `error` is `Some` and the error is a [`TokenValidationError::Server`], returns
/// an empty `Vec` — server-side failures (e.g. unreachable introspection endpoint) use
/// a 5xx status code and no `WWW-Authenticate` header, since re-authenticating would
/// not resolve the failure.
///
/// If both Bearer and DPoP are supported, challenges for both are returned, as
/// recommended by RFC 9449 §7.1. Per RFC 7235, the challenges may be sent as
/// separate `WWW-Authenticate` headers or joined with `, ` on a single header —
/// both forms are equivalent.
///
/// # Example
///
/// ```
/// # use huskarl_resource_server::validator::metadata::ValidatorMetadata;
/// let metadata = ValidatorMetadata {
/// realm: Some("example".to_string()),
/// authorization_servers: None,
/// dpop_signing_alg_values_supported: Some(vec!["ES256".to_string()]),
/// dpop_bound_access_tokens_required: Some(false),
/// resource: None,
/// bearer_methods_supported: None,
/// };
/// let challenges = metadata.challenges(None, Some("read write"), None);
/// assert_eq!(challenges.len(), 2);
/// assert_eq!(challenges[0], r#"Bearer realm="example", scope="read write""#);
/// assert_eq!(challenges[1], r#"DPoP realm="example", scope="read write", algs="ES256""#);
/// ```
#[must_use]
pub fn challenges(
&self,
error: Option<&dyn ToRfc6750Error>,
scope: Option<&str>,
error_uri: Option<&str>,
) -> Vec<String> {
let mut challenges = Vec::new();
let attempted_scheme = error.and_then(|e| e.attempted_scheme());
let dpop_supported = self.dpop_signing_alg_values_supported.is_some()
|| self.dpop_bound_access_tokens_required == Some(true);
let bearer_allowed = !self.dpop_bound_access_tokens_required.unwrap_or(false);
// For server errors (5xx), omit WWW-Authenticate entirely — including it would
// mislead clients into thinking re-authenticating would resolve the failure.
if error.is_some_and(|e| matches!(e.token_error(), TokenValidationError::Server(_))) {
return Vec::new();
}
let is_client_error =
error.is_some_and(|e| matches!(e.token_error(), TokenValidationError::Client(_)));
let include_in_dpop = dpop_supported
&& is_client_error
&& (attempted_scheme.is_none() || attempted_scheme == Some(TokenType::DPoP));
let include_in_bearer = bearer_allowed
&& is_client_error
&& (attempted_scheme.is_none() || attempted_scheme == Some(TokenType::Bearer));
if bearer_allowed {
let mut bearer_parts = Vec::new();
if let Some(realm) = self.realm.as_deref() {
bearer_parts.push(format!(r#"realm="{}""#, escape_quoted(realm)));
}
if let Some(scope) = scope {
bearer_parts.push(format!(r#"scope="{}""#, escape_quoted(scope)));
}
if include_in_bearer
&& let Some(e) = error
&& let TokenValidationError::Client(code) = e.token_error()
{
bearer_parts.push(format!(r#"error="{}""#, code.as_str()));
if let Some(desc) = e.error_description() {
bearer_parts.push(format!(r#"error_description="{}""#, escape_quoted(&desc)));
}
if let Some(uri) = error_uri {
bearer_parts.push(format!(r#"error_uri="{}""#, escape_quoted(uri)));
}
bearer_parts.extend(e.extra_params().into_iter().map(|p| p.format()));
}
let mut bearer = "Bearer".to_string();
if !bearer_parts.is_empty() {
bearer.push(' ');
bearer.push_str(&bearer_parts.join(", "));
}
challenges.push(bearer);
}
if dpop_supported {
let mut dpop_parts = Vec::new();
if let Some(realm) = self.realm.as_deref() {
dpop_parts.push(format!(r#"realm="{}""#, escape_quoted(realm)));
}
if let Some(scope) = scope {
dpop_parts.push(format!(r#"scope="{}""#, escape_quoted(scope)));
}
if include_in_dpop
&& let Some(e) = error
&& let TokenValidationError::Client(code) = e.token_error()
{
dpop_parts.push(format!(r#"error="{}""#, code.as_str()));
if let Some(desc) = e.error_description() {
dpop_parts.push(format!(r#"error_description="{}""#, escape_quoted(&desc)));
}
if let Some(uri) = error_uri {
dpop_parts.push(format!(r#"error_uri="{}""#, escape_quoted(uri)));
}
dpop_parts.extend(e.extra_params().into_iter().map(|p| p.format()));
}
if let Some(algs) = &self.dpop_signing_alg_values_supported {
dpop_parts.push(format!(r#"algs="{}""#, algs.join(" ")));
}
let mut dpop = "DPoP".to_string();
if !dpop_parts.is_empty() {
dpop.push(' ');
dpop.push_str(&dpop_parts.join(", "));
}
challenges.push(dpop);
}
challenges
}
/// Returns the `WWW-Authenticate` header values for an unauthenticated request.
///
/// Equivalent to calling [`Self::challenges(None, scope, None)`].
#[must_use]
pub fn unauthenticated_challenges(&self, scope: Option<&str>) -> Vec<String> {
self.challenges(None, scope, None)
}
}
/// A trait for validators that can describe their configuration.
///
/// The returned [`ValidatorMetadata`] can be used to populate a Protected Resource
/// Metadata document (RFC 9728).
pub trait ProvideValidatorMetadata {
/// Returns metadata describing how this validator is configured.
///
/// The resource is the URL of the protected resource.
///
fn validator_metadata(&self, resource: Option<&str>) -> ValidatorMetadata;
}