Skip to main content

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}