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}