Skip to main content

oauth_resource_server/
token.rs

1//! What a validation produces: the accepted token ([`AuthorizedToken`]) or the
2//! reason it was refused ([`TokenRejection`]), plus the claim readers and the
3//! header `typ` check that feed them.
4
5use std::collections::HashSet;
6
7use serde_json::{Map, Value};
8
9use crate::config::KeyNamingBuf;
10
11/// Cap on a presented credential. Real access tokens are well under 8 KiB even
12/// with group claims; anything larger is refused before it is base64-decoded.
13pub(crate) const MAX_TOKEN_BYTES: usize = 16 * 1024;
14
15/// Cap on any token-derived string that reaches a log line (`kid`, `typ`,
16/// principal). A signed claim is trustworthy but not necessarily short, and an
17/// unverified header field is neither.
18pub(crate) const MAX_LOGGED_CHARS: usize = 128;
19
20/// A successfully validated access token. The axum middleware (feature `axum`)
21/// inserts it into request extensions, so handlers can read who called and with
22/// which scopes.
23///
24/// The scopes come from the one place that actually verified them, so a handler
25/// enforcing a finer-grained scope (say, a write scope on some routes) should ask
26/// [`AuthorizedToken::has_scope`] rather than re-parse the header.
27#[derive(Debug, Clone, PartialEq, Eq)]
28#[non_exhaustive]
29pub struct AuthorizedToken {
30    /// The token's `sub`, verbatim, when it carried one as a string. It is
31    /// signed, so it is safe to key per-user decisions on (it is bounded only
32    /// by the 16 KiB credential cap); this crate's own log lines truncate it.
33    pub subject: Option<String>,
34    /// The first present, non-empty string claim of
35    /// [`crate::OAuthConfig::principal_claims`], verbatim — who the request is
36    /// from, for logs and attribution. Never the token itself. Which claim it
37    /// came from depends on config, so key authorization on
38    /// [`AuthorizedToken::subject`] rather than on this.
39    pub principal: Option<String>,
40    /// The union of every [`crate::OAuthConfig::scope_claims`] claim, in
41    /// first-seen order, deduplicated.
42    pub scopes: Vec<String>,
43}
44
45impl AuthorizedToken {
46    /// Build a token record from parts, with `scopes` deduplicated in
47    /// first-seen order (blank entries dropped), as a validation produces them.
48    ///
49    /// This verifies nothing — it is a plain value constructor for code that
50    /// needs an `AuthorizedToken` without a validation, chiefly tests that place
51    /// one in request extensions. [`crate::OAuthValidator::validate`] is the only
52    /// source of a token that was actually checked. The struct is
53    /// `#[non_exhaustive]`, so a field added later gets a default here rather
54    /// than breaking callers.
55    pub fn new(
56        subject: Option<String>,
57        principal: Option<String>,
58        scopes: impl IntoIterator<Item = impl Into<String>>,
59    ) -> Self {
60        let mut deduped: Vec<String> = Vec::new();
61        for scope in scopes {
62            let scope = scope.into();
63            let scope = scope.trim();
64            if !scope.is_empty() && !deduped.iter().any(|s| s == scope) {
65                deduped.push(scope.to_string());
66            }
67        }
68        Self {
69            subject,
70            principal,
71            scopes: deduped,
72        }
73    }
74
75    /// Whether the token carries `scope` (exact, case-sensitive match, RFC 6749
76    /// §3.3). The single place that answers the question, so callers never
77    /// hand-roll a `.iter().any()` over `scopes`.
78    ///
79    /// # Examples
80    ///
81    /// ```
82    /// use oauth_resource_server::AuthorizedToken;
83    ///
84    /// let token = AuthorizedToken::new(Some("user-1".into()), None, ["api:read", "api:write"]);
85    /// assert!(token.has_scope("api:write"));
86    /// assert!(!token.has_scope("API:WRITE"));
87    /// ```
88    pub fn has_scope(&self, scope: &str) -> bool {
89        self.scopes.iter().any(|s| s == scope)
90    }
91}
92
93/// Why a bearer credential was refused, and — crucially — with which HTTP status.
94///
95/// The split is the whole point: RFC 6750 distinguishes "this token is not good"
96/// (401 `invalid_token`, go get a new one) from "this token is fine but not
97/// sufficient" (403 `insufficient_scope`). A client that gets 401 for an
98/// insufficient-scope token will loop through the authorization flow forever and
99/// land back on the same refusal.
100///
101/// | Variant | Status | `WWW-Authenticate` (with OAuth configured) |
102/// |---|---|---|
103/// | [`Missing`](Self::Missing) | 401 | [`crate::OAuthValidator::invalid_token_challenge`] |
104/// | [`Invalid`](Self::Invalid) | 401 | [`crate::OAuthValidator::invalid_token_challenge`] |
105/// | [`InsufficientScope`](Self::InsufficientScope) | 403 | [`crate::OAuthValidator::insufficient_scope_challenge`] |
106///
107/// `#[non_exhaustive]`: treat a variant this code does not know as a 401.
108///
109/// It implements [`std::error::Error`], so `?` carries it into
110/// `Box<dyn Error>` or `anyhow`. `Display` deliberately renders the category
111/// only — `missing credential`, `invalid token`, `insufficient scope` — and
112/// never [`Invalid`](Self::Invalid)'s reason, so a careless `format!("{e}")`
113/// in a response body cannot tell a caller which check failed. The reason is
114/// reachable through the variant itself and through `Debug`, for logs.
115///
116/// ```
117/// use oauth_resource_server::TokenRejection;
118///
119/// let e = TokenRejection::Invalid("token rejected: InvalidAudience".into());
120/// assert_eq!(e.to_string(), "invalid token");
121/// assert!(format!("{e:?}").contains("InvalidAudience"));
122/// ```
123#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
124#[non_exhaustive]
125pub enum TokenRejection {
126    /// 401: the request carried no credential at all. Separate from `Invalid` so
127    /// a server can log it quietly — every OAuth client's first request looks
128    /// like this — not because the response differs (it should not: a missing
129    /// credential gets the same `invalid_token` challenge as a bad one).
130    #[error("missing credential")]
131    Missing,
132    /// 401 `invalid_token`: malformed, unsigned, wrong issuer/audience/type,
133    /// expired, or signed by a key we could not obtain. The string is for logs
134    /// only — never return it to the caller, since telling an unauthenticated
135    /// client exactly which check failed is a free oracle.
136    #[error("invalid token")]
137    Invalid(String),
138    /// 403 `insufficient_scope`: signature, issuer, audience and expiry all
139    /// passed, but the token does not carry every required scope.
140    #[error("insufficient scope")]
141    InsufficientScope,
142}
143
144/// The union of every configured scope claim, in first-seen order, deduplicated.
145///
146/// Every claim is read in every shape: a string is split on whitespace (RFC 9068
147/// §2.2.3's `scope`, and Entra ID's / Hydra's string `scp`), an array contributes
148/// each string element whole (Authelia's and Okta's `scp`). Anything else — a
149/// number, an object, a claim the token does not have — contributes nothing rather
150/// than failing the token, since the only consequence of "no scopes found" is the
151/// 403 for a missing required scope, which is the correct answer anyway.
152pub(crate) fn extract_scopes(claims: &Map<String, Value>, claim_names: &[String]) -> Vec<String> {
153    let mut seen = HashSet::new();
154    let mut out = Vec::new();
155    let mut push = |s: &str| {
156        let s = s.trim();
157        if !s.is_empty() && seen.insert(s.to_string()) {
158            out.push(s.to_string());
159        }
160    };
161    for name in claim_names {
162        match claims.get(name) {
163            Some(Value::String(s)) => s.split_whitespace().for_each(&mut push),
164            Some(Value::Array(items)) => items.iter().filter_map(Value::as_str).for_each(&mut push),
165            _ => {}
166        }
167    }
168    out
169}
170
171/// The first present, non-empty string claim among `claim_names`, verbatim.
172/// Truncate it (`for_log`) where it is logged, never here.
173pub(crate) fn extract_principal(
174    claims: &Map<String, Value>,
175    claim_names: &[String],
176) -> Option<String> {
177    claim_names.iter().find_map(|name| match claims.get(name) {
178        Some(Value::String(s)) if !s.trim().is_empty() => Some(s.clone()),
179        _ => None,
180    })
181}
182
183/// RFC 9068 §2.1 / §4: the header `typ` of a JWT access token is `at+jwt`
184/// (`application/at+jwt` is the same media type, RFC 7515 §4.1.9, compared
185/// case-insensitively). Many servers still emit `JWT` or nothing (Authentik, Entra
186/// ID, Okta, Keycloak by default), so those pass unless `require_at_jwt` is on —
187/// which an operator whose AS does emit `at+jwt` (Authelia, Kanidm) should turn on,
188/// since it is the one check that tells an access token from an ID token minted
189/// for the same client. Any OTHER explicit type (`dpop+jwt`, `logout+jwt`,
190/// `secevent+jwt`...) is a different kind of JWT and is always refused.
191///
192/// `naming` only shapes the (log-only) rejection reason.
193pub(crate) fn check_typ(
194    typ: Option<&str>,
195    require_at_jwt: bool,
196    naming: &KeyNamingBuf,
197) -> Result<(), TokenRejection> {
198    let Some(raw) = typ else {
199        return if require_at_jwt {
200            Err(TokenRejection::Invalid(format!(
201                "token header has no typ and {} is on",
202                naming.key("require_at_jwt")
203            )))
204        } else {
205            Ok(())
206        };
207    };
208    let lower = raw.trim().to_ascii_lowercase();
209    let media = lower.strip_prefix("application/").unwrap_or(&lower);
210    match media {
211        "at+jwt" => Ok(()),
212        "jwt" if !require_at_jwt => Ok(()),
213        _ => Err(TokenRejection::Invalid(format!(
214            "token typ {:?} is not accepted as an access token{}",
215            for_log(raw),
216            if require_at_jwt {
217                format!(" ({} is on)", naming.key("require_at_jwt"))
218            } else {
219                String::new()
220            }
221        ))),
222    }
223}
224
225/// Truncate a token-derived string for a log line. See [`MAX_LOGGED_CHARS`].
226pub(crate) fn for_log(s: &str) -> String {
227    let mut out: String = s.chars().take(MAX_LOGGED_CHARS).collect();
228    if s.chars().count() > MAX_LOGGED_CHARS {
229        out.push('…');
230    }
231    out
232}
233
234/// A `kid` for a log line or rejection reason: quoted and truncated, or `(none)`.
235pub(crate) fn describe_kid(kid: Option<&str>) -> String {
236    match kid {
237        Some(kid) => format!("{:?}", for_log(kid)),
238        None => "(none)".to_string(),
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn authorized_token_new_dedupes_scopes_like_a_validation() {
248        let t = AuthorizedToken::new(Some("sub-1".into()), None, ["b", " a", "b", "", "a", "c"]);
249        assert_eq!(t.subject.as_deref(), Some("sub-1"));
250        assert_eq!(t.principal, None);
251        assert_eq!(t.scopes, ["b", "a", "c"]);
252        assert!(t.has_scope("a"));
253        assert!(!t.has_scope(""));
254        let none = AuthorizedToken::new(None, None, Vec::<String>::new());
255        assert!(none.scopes.is_empty());
256    }
257
258    #[test]
259    fn a_rejection_displays_its_category_but_never_the_reason() {
260        let secret_reason = "token rejected: InvalidAudience";
261        let invalid = TokenRejection::Invalid(secret_reason.into());
262        assert_eq!(invalid.to_string(), "invalid token");
263        assert!(!invalid.to_string().contains("InvalidAudience"));
264        assert!(format!("{invalid:?}").contains(secret_reason));
265        assert_eq!(TokenRejection::Missing.to_string(), "missing credential");
266        assert_eq!(
267            TokenRejection::InsufficientScope.to_string(),
268            "insufficient scope"
269        );
270        let boxed: Box<dyn std::error::Error + Send + Sync> = Box::new(invalid);
271        assert_eq!(boxed.to_string(), "invalid token");
272    }
273
274    #[test]
275    fn logged_values_are_truncated() {
276        let long = "x".repeat(MAX_LOGGED_CHARS * 3);
277        assert_eq!(for_log(&long).chars().count(), MAX_LOGGED_CHARS + 1);
278        assert_eq!(for_log("short"), "short");
279    }
280
281    #[test]
282    fn typ_rejection_reasons_name_the_setting_per_key_naming() {
283        let dotted = KeyNamingBuf::Dotted("mcp.oauth".into());
284        assert_eq!(
285            check_typ(None, true, &dotted),
286            Err(TokenRejection::Invalid(
287                "token header has no typ and mcp.oauth.require_at_jwt is on".into()
288            ))
289        );
290        assert_eq!(
291            check_typ(Some("JWT"), true, &dotted),
292            Err(TokenRejection::Invalid(
293                "token typ \"JWT\" is not accepted as an access token \
294                 (mcp.oauth.require_at_jwt is on)"
295                    .into()
296            ))
297        );
298        assert_eq!(
299            check_typ(Some("dpop+jwt"), false, &dotted),
300            Err(TokenRejection::Invalid(
301                "token typ \"dpop+jwt\" is not accepted as an access token".into()
302            ))
303        );
304        let env = KeyNamingBuf::Env("APP_OAUTH_".into());
305        assert_eq!(
306            check_typ(None, true, &env),
307            Err(TokenRejection::Invalid(
308                "token header has no typ and APP_OAUTH_REQUIRE_AT_JWT is on".into()
309            ))
310        );
311    }
312}