oauth_resource_server/refusal.rs
1//! The framework-free mapping from a [`TokenRejection`] to the HTTP refusal it
2//! calls for: the status (RFC 6750 §3.1: 401 for a missing or invalid
3//! credential, 403 for a valid token without the required scopes) and the
4//! `WWW-Authenticate` challenge.
5//!
6//! [`refusal`] and [`refusal_with_static_challenge`] are for an HTTP stack this
7//! crate has no integration for (hyper, actix-web, poem, ...), which calls
8//! [`crate::authenticate()`] itself. The `axum` and `tower` layers make the
9//! same decision through the same private function ([`select`]), so a hand-built
10//! integration and the provided layers cannot disagree about a status or a
11//! challenge.
12
13use crate::challenge::is_header_value;
14use crate::token::TokenRejection;
15use crate::validator::OAuthValidator;
16
17/// The `WWW-Authenticate` value a refusal carries when no OAuth validator is
18/// configured, unless the application chose another (the layers'
19/// `static_challenge`, or [`refusal_with_static_challenge`]).
20///
21/// RFC 9110 §15.5.2 requires a 401 to carry at least one challenge, and RFC
22/// 6750 §3 a `Bearer` challenge with at least one parameter. This is the same
23/// `invalid_token` challenge an OAuth refusal sends (without the
24/// `resource_metadata` there is nothing to point at), for a missing credential
25/// as well as a wrong one.
26pub const DEFAULT_STATIC_CHALLENGE: &str = "Bearer error=\"invalid_token\"";
27
28/// The 403 challenge a per-request scope refusal carries when no OAuth
29/// validator is configured (RFC 6750 §3.1's `insufficient_scope`, with no
30/// `resource_metadata` or `scope` to name).
31pub(crate) const BARE_INSUFFICIENT_SCOPE_CHALLENGE: &str = "Bearer error=\"insufficient_scope\"";
32
33/// The HTTP refusal a [`TokenRejection`] calls for; see [`refusal`].
34///
35/// `#[non_exhaustive]`: read its fields; more may be added without a breaking
36/// change.
37#[derive(Debug, Clone, PartialEq, Eq)]
38#[non_exhaustive]
39pub struct Refusal {
40 /// `401` (missing or invalid credential) or `403` (a valid token without
41 /// the required scopes).
42 pub status: u16,
43 /// The `WWW-Authenticate` value to send, replacing any other. `None` only
44 /// when no OAuth validator is configured and the application opted out of
45 /// the static challenge ([`refusal_with_static_challenge`] with `None`).
46 pub www_authenticate: Option<String>,
47}
48
49/// The status and `WWW-Authenticate` challenge to answer a refused request
50/// with — exactly what the `axum` and `tower` layers send.
51///
52/// - [`TokenRejection::InsufficientScope`] is `403`; everything else
53/// ([`Missing`](TokenRejection::Missing), [`Invalid`](TokenRejection::Invalid),
54/// any future variant) is `401`.
55/// - With `oauth`, the challenge is the validator's:
56/// [`OAuthValidator::insufficient_scope_challenge`] for a 403,
57/// [`OAuthValidator::invalid_token_challenge`] for every 401 — a request
58/// with NO credential included, since `resource_metadata` in that challenge
59/// is how a client finds the authorization server (RFC 6750 §3.1's "SHOULD
60/// NOT include an error code" for that case is deliberately not followed).
61/// - Without `oauth`, the challenge is [`DEFAULT_STATIC_CHALLENGE`]. Use
62/// [`refusal_with_static_challenge`] for another, or for none.
63///
64/// Send the status and set (not append) `WWW-Authenticate` on every refusal,
65/// whatever body you build. Never put [`TokenRejection::Invalid`]'s reason in
66/// the response: it is for your log.
67///
68/// # Examples
69///
70/// ```
71/// use oauth_resource_server::{DEFAULT_STATIC_CHALLENGE, TokenRejection, refusal};
72///
73/// // A static-token-only service (no OAuth validator).
74/// let r = refusal(&TokenRejection::Missing, None);
75/// assert_eq!(r.status, 401);
76/// assert_eq!(r.www_authenticate.as_deref(), Some(DEFAULT_STATIC_CHALLENGE));
77/// ```
78///
79/// With a validator, in any HTTP stack:
80///
81/// ```no_run
82/// use oauth_resource_server::{OAuthValidator, authenticate, refusal};
83///
84/// # async fn handle(oauth: &OAuthValidator, authorization: Option<&str>) {
85/// // The scheme is case-insensitive (RFC 9110 §11.1): `bearer x` is `Bearer x`.
86/// let bearer = authorization
87/// .and_then(|v| v.split_once(' '))
88/// .filter(|(scheme, _)| scheme.eq_ignore_ascii_case("bearer"))
89/// .map(|(_, token)| token.trim());
90/// match authenticate(bearer, None, Some(oauth)).await {
91/// Ok(_credential) => { /* serve the request */ }
92/// Err(rejection) => {
93/// let r = refusal(&rejection, Some(oauth));
94/// // Respond with `r.status`, and `WWW-Authenticate: <value>` for
95/// // `r.www_authenticate`.
96/// # let _ = r;
97/// }
98/// }
99/// # }
100/// ```
101pub fn refusal(rejection: &TokenRejection, oauth: Option<&OAuthValidator>) -> Refusal {
102 refusal_with_static_challenge(rejection, oauth, Some(DEFAULT_STATIC_CHALLENGE))
103}
104
105/// [`refusal`], with the challenge to send when `oauth` is `None` chosen by the
106/// caller — the counterpart of the layers' `static_challenge` setting.
107/// `static_challenge` is ignored when `oauth` is `Some`: with OAuth, every
108/// refusal carries the validator's challenge.
109///
110/// `Some(value)` sends `value` (`Bearer realm="my-api"`, say, or your own
111/// scheme for an API-key header); `None` sends no challenge at all. That
112/// departs from RFC 9110 §15.5.2 (a 401 MUST carry a challenge); use it only
113/// to keep an existing API's responses unchanged.
114///
115/// # Security
116///
117/// The returned challenge is always a valid header value. A `static_challenge`
118/// holding any byte other than visible ASCII, SP or HTAB (a CR or LF above
119/// all, which would split the header) is not sent: [`DEFAULT_STATIC_CHALLENGE`]
120/// is sent in its place. With OAuth the validator's challenges are used,
121/// which are always valid too: a validator built from a hand-edited config
122/// whose challenges would not be falls back to `Bearer error="invalid_token"`
123/// / `Bearer error="insufficient_scope"` (with `scope` only when valid) and
124/// logs that at `error`.
125///
126/// # Examples
127///
128/// ```
129/// use oauth_resource_server::{TokenRejection, refusal_with_static_challenge};
130///
131/// let r = refusal_with_static_challenge(
132/// &TokenRejection::Invalid("wrong key".into()),
133/// None,
134/// Some("Bearer realm=\"my-api\""),
135/// );
136/// assert_eq!(r.status, 401);
137/// assert_eq!(r.www_authenticate.as_deref(), Some("Bearer realm=\"my-api\""));
138///
139/// let r = refusal_with_static_challenge(&TokenRejection::Missing, None, None);
140/// assert_eq!(r.www_authenticate, None);
141/// ```
142pub fn refusal_with_static_challenge(
143 rejection: &TokenRejection,
144 oauth: Option<&OAuthValidator>,
145 static_challenge: Option<&str>,
146) -> Refusal {
147 let oauth_challenges = oauth.map(|v| {
148 (
149 v.invalid_token_challenge(),
150 v.insufficient_scope_challenge(),
151 )
152 });
153 let (status, challenge) = select(
154 rejection,
155 oauth_challenges
156 .as_ref()
157 .map(|(i, s)| (i.as_str(), s.as_str())),
158 // Never hand out a value that is not a header value (see `# Security`).
159 static_challenge.map(|c| {
160 if is_header_value(c) {
161 c
162 } else {
163 DEFAULT_STATIC_CHALLENGE
164 }
165 }),
166 );
167 Refusal {
168 status,
169 www_authenticate: challenge.map(str::to_owned),
170 }
171}
172
173/// [`refusal`] for a request that needed `scopes` on top of the validator's
174/// own required scopes — a per-route or per-operation requirement checked
175/// with [`crate::AuthorizedToken::require_scopes`]. Everything but the 403
176/// challenge is exactly [`refusal`]'s: the same status for every rejection,
177/// the same 401 challenge, [`DEFAULT_STATIC_CHALLENGE`] on a 401 without
178/// OAuth. Without OAuth a 403 carries `Bearer error="insufficient_scope"`
179/// (RFC 6750 §3.1) rather than the static 401 challenge [`refusal`] gives.
180///
181/// With OAuth, [`TokenRejection::InsufficientScope`] carries
182/// [`OAuthValidator::insufficient_scope_challenge_for`] naming the
183/// validator's `required_scopes` followed by `scopes` (deduplicated): every
184/// scope this request needs, so a client that re-authorizes for exactly that
185/// set passes both checks. `description` becomes its `error_description`
186/// (sanitized as that method documents; `None` for none). This is the same
187/// challenge the layers' `require_scopes`, the `RequireScopes` route layer,
188/// the `Scoped` extractor and the `mcp` feature send for the same scopes.
189///
190/// # Examples
191///
192/// ```no_run
193/// use oauth_resource_server::{
194/// Credential, OAuthValidator, TokenRejection, authenticate, refusal, refusal_for_scopes,
195/// };
196///
197/// # async fn handle(oauth: &OAuthValidator, bearer: Option<&str>) {
198/// let token = match authenticate(bearer, None, Some(oauth)).await {
199/// Ok(Credential::OAuth(token)) => token,
200/// Ok(_) => unreachable!("no static token was given"),
201/// Err(rejection) => {
202/// let r = refusal(&rejection, Some(oauth));
203/// # let _ = r;
204/// return; // respond with r.status and r.www_authenticate
205/// }
206/// };
207/// // This operation needs a write scope on top of the validator's floor.
208/// if let Err(missing) = token.require_scopes(&["docs:write"]) {
209/// let r = refusal_for_scopes(&missing.into(), Some(oauth), &["docs:write"], None);
210/// assert_eq!(r.status, 403);
211/// # let _ = r;
212/// return; // respond with r.status and r.www_authenticate
213/// }
214/// # }
215/// ```
216pub fn refusal_for_scopes(
217 rejection: &TokenRejection,
218 oauth: Option<&OAuthValidator>,
219 scopes: &[&str],
220 description: Option<&str>,
221) -> Refusal {
222 let oauth_challenges = oauth.map(|v| {
223 (
224 v.invalid_token_challenge(),
225 v.insufficient_scope_challenge_for(&v.scopes_with_floor(scopes), description),
226 )
227 });
228 let (status, challenge) = select(
229 rejection,
230 oauth_challenges
231 .as_ref()
232 .map(|(i, s)| (i.as_str(), s.as_str())),
233 Some(DEFAULT_STATIC_CHALLENGE),
234 );
235 // Without OAuth a 403 still names its error (RFC 6750 §3.1), as the
236 // layers' route-level refusals do: the static 401 challenge would not.
237 let challenge = match (rejection, oauth) {
238 (TokenRejection::InsufficientScope, None) => Some(BARE_INSUFFICIENT_SCOPE_CHALLENGE),
239 _ => challenge,
240 };
241 Refusal {
242 status,
243 www_authenticate: challenge.map(str::to_owned),
244 }
245}
246
247/// The one decision behind every refusal this crate builds: the status for
248/// `rejection`, and which of the given challenges it carries.
249/// `oauth_challenges` is `(invalid_token, insufficient_scope)`, `Some` exactly
250/// when OAuth is configured.
251///
252/// Generic over the challenge's representation so the public [`refusal`]
253/// (strings) and the layers (pre-validated `http::HeaderValue`s, which may hold
254/// bytes a `&str` cannot) share it without a fallible conversion per request.
255pub(crate) fn select<'c, C: ?Sized>(
256 rejection: &TokenRejection,
257 oauth_challenges: Option<(&'c C, &'c C)>,
258 static_challenge: Option<&'c C>,
259) -> (u16, Option<&'c C>) {
260 let status = match rejection {
261 TokenRejection::InsufficientScope => 403,
262 // `Missing`, `Invalid`, and any future variant: never a pass.
263 _ => 401,
264 };
265 let challenge = match (oauth_challenges, rejection) {
266 (Some((_, insufficient)), TokenRejection::InsufficientScope) => Some(insufficient),
267 (Some((invalid, _)), _) => Some(invalid),
268 (None, _) => static_challenge,
269 };
270 (status, challenge)
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 #[test]
278 fn status_follows_rfc_6750() {
279 let cases = [
280 (TokenRejection::Missing, 401),
281 (TokenRejection::Invalid("x".into()), 401),
282 (TokenRejection::InsufficientScope, 403),
283 ];
284 for (rejection, status) in cases {
285 assert_eq!(refusal(&rejection, None).status, status, "{rejection:?}");
286 }
287 }
288
289 #[test]
290 fn a_static_challenge_that_is_not_a_header_value_falls_back_to_the_default() {
291 for bad in [
292 "Bearer realm=\"x\"\r\nSet-Cookie: a=b",
293 "Bearer\nx",
294 "Bearer \u{7f}",
295 "Bearer réalm",
296 ] {
297 assert_eq!(
298 refusal_with_static_challenge(&TokenRejection::Missing, None, Some(bad))
299 .www_authenticate
300 .as_deref(),
301 Some(DEFAULT_STATIC_CHALLENGE),
302 "{bad:?}"
303 );
304 }
305 // SP and HTAB are allowed.
306 assert_eq!(
307 refusal_with_static_challenge(&TokenRejection::Missing, None, Some("A\tb c"))
308 .www_authenticate
309 .as_deref(),
310 Some("A\tb c")
311 );
312 }
313
314 #[test]
315 fn a_validator_whose_challenge_is_not_a_header_value_builds_with_fallbacks() {
316 let rejections = [
317 TokenRejection::Missing,
318 TokenRejection::Invalid("x".into()),
319 TokenRejection::InsufficientScope,
320 ];
321 // A CR/LF in `resource`: the validator still builds, and every
322 // refusal carries a valid fallback (the scopes are fine, so kept).
323 let mut cfg = crate::testing::resolved_config("http://127.0.0.1:1/jwks");
324 cfg.resource = "https://api.example.test/v1\r\nX-Injected: 1".into();
325 let v = OAuthValidator::new(&cfg).expect("a hand-edited config still builds");
326 assert!(v.challenge_fell_back());
327 let got: Vec<_> = rejections
328 .iter()
329 .map(|r| refusal(r, Some(&v)).www_authenticate.unwrap())
330 .collect();
331 assert_eq!(
332 got,
333 [
334 "Bearer error=\"invalid_token\", scope=\"mcp:read mcp:write\"",
335 "Bearer error=\"invalid_token\", scope=\"mcp:read mcp:write\"",
336 "Bearer error=\"insufficient_scope\", scope=\"mcp:read\"",
337 ]
338 );
339 // An invalid scope: the fallback drops the attribute it cannot send.
340 let mut cfg = crate::testing::resolved_config("http://127.0.0.1:1/jwks");
341 cfg.scopes_supported = vec!["read\u{0}".into()];
342 let v = OAuthValidator::new(&cfg).unwrap();
343 assert_eq!(
344 refusal(&TokenRejection::Missing, Some(&v))
345 .www_authenticate
346 .unwrap(),
347 "Bearer error=\"invalid_token\""
348 );
349 // The fixture itself does not fall back, and its challenges are valid.
350 let v = OAuthValidator::new(&crate::testing::resolved_config("http://127.0.0.1:1/jwks"))
351 .unwrap();
352 assert!(!v.challenge_fell_back());
353 for rejection in &rejections {
354 let challenge = refusal(rejection, Some(&v)).www_authenticate.unwrap();
355 assert!(is_header_value(&challenge), "{challenge}");
356 assert!(challenge.contains("resource_metadata="), "{challenge}");
357 }
358 }
359
360 #[test]
361 fn refusal_for_scopes_differs_from_refusal_only_in_the_403_challenge() {
362 let v = OAuthValidator::new(&crate::testing::resolved_config("http://127.0.0.1:1/jwks"))
363 .unwrap();
364 for rejection in [TokenRejection::Missing, TokenRejection::Invalid("x".into())] {
365 assert_eq!(
366 refusal_for_scopes(&rejection, Some(&v), &["mcp:write"], Some("d")),
367 refusal(&rejection, Some(&v))
368 );
369 assert_eq!(
370 refusal_for_scopes(&rejection, None, &["mcp:write"], None),
371 refusal(&rejection, None)
372 );
373 }
374 let r = refusal_for_scopes(
375 &TokenRejection::InsufficientScope,
376 Some(&v),
377 &["mcp:write", "mcp:read"],
378 Some("write needed"),
379 );
380 assert_eq!(r.status, 403);
381 // The validator's floor first, then the request's own, once each.
382 assert_eq!(
383 r.www_authenticate.as_deref(),
384 Some(
385 "Bearer error=\"insufficient_scope\", scope=\"mcp:read mcp:write\", \
386 resource_metadata=\"https://kb.example.test/.well-known/oauth-protected-resource/mcp\", \
387 error_description=\"write needed\""
388 )
389 );
390 // No extra scopes: exactly `refusal()`'s 403.
391 assert_eq!(
392 refusal_for_scopes(&TokenRejection::InsufficientScope, Some(&v), &[], None),
393 refusal(&TokenRejection::InsufficientScope, Some(&v))
394 );
395 // Without OAuth a 403 carries the bare `insufficient_scope`
396 // challenge, never the static 401 one.
397 let r = refusal_for_scopes(&TokenRejection::InsufficientScope, None, &["x"], None);
398 assert_eq!(r.status, 403);
399 assert_eq!(
400 r.www_authenticate.as_deref(),
401 Some("Bearer error=\"insufficient_scope\"")
402 );
403 }
404
405 #[test]
406 fn static_challenge_is_the_default_unless_overridden() {
407 for rejection in [
408 TokenRejection::Missing,
409 TokenRejection::Invalid("x".into()),
410 TokenRejection::InsufficientScope,
411 ] {
412 assert_eq!(
413 refusal(&rejection, None).www_authenticate.as_deref(),
414 Some(DEFAULT_STATIC_CHALLENGE)
415 );
416 assert_eq!(
417 refusal_with_static_challenge(&rejection, None, Some("Custom x"))
418 .www_authenticate
419 .as_deref(),
420 Some("Custom x")
421 );
422 assert_eq!(
423 refusal_with_static_challenge(&rejection, None, None).www_authenticate,
424 None
425 );
426 }
427 }
428}