#[non_exhaustive]pub struct AuthorizedToken {
pub subject: Option<String>,
pub principal: Option<String>,
pub scopes: Vec<String>,
pub issuer: String,
pub audiences: Vec<String>,
pub expires_at: SystemTime,
pub issued_at: Option<SystemTime>,
pub client_id: Option<String>,
pub jti: Option<String>,
/* private fields */
}Expand description
A successfully validated access token. The axum middleware (feature axum)
inserts it into request extensions, so handlers can read who called and with
which scopes.
The scopes come from the one place that actually verified them, so a handler
enforcing a finer-grained scope (say, a write scope on some routes) should ask
AuthorizedToken::has_scope rather than re-parse the header.
§The verified claims
Besides the fields below, the token keeps the whole claim set the signature
covered, read with AuthorizedToken::claims (raw) or
AuthorizedToken::claims_as (into your own type), for anything this crate
has no field for: groups, roles, email, a tenant id. There is no need to
decode the JWT a second time in a handler. The claims are stored once and
shared (Arc), so cloning a token is cheap. Their size is bounded by the
credential cap: a token is refused above 16 KiB before it is decoded, so
the stored claims cannot outgrow that (their parsed in-memory form is a small
multiple of it).
§Debug
Debug prints every field except the claim values: the claims appear as
their names only. Claims routinely carry personal data (email, name,
group memberships) and a {token:?} in a log line or a panic message must
not leak it. subject, principal and client_id are printed, as they
always have been (subject, principal) or are identifiers meant for logs.
Read values deliberately through AuthorizedToken::claims.
Fields (Non-exhaustive)§
This struct is marked as non-exhaustive
Struct { .. } syntax; cannot be matched against without a wildcard ..; and struct update syntax will not work.subject: Option<String>The token’s sub, verbatim, when it carried one as a string. It is
signed, so it is safe to key per-user decisions on (it is bounded only
by the 16 KiB credential cap); this crate’s own log lines truncate it.
principal: Option<String>The first present, non-empty string claim of
crate::OAuthConfig::principal_claims, verbatim — who the request is
from, for logs and attribution. Never the token itself. Which claim it
came from depends on config, so key authorization on
AuthorizedToken::subject rather than on this.
scopes: Vec<String>The union of every crate::OAuthConfig::scope_claims claim, in
first-seen order, deduplicated.
issuer: StringThe token’s iss: the exact string that matched the configured issuer.
Empty on a token built with AuthorizedToken::new.
audiences: Vec<String>The token’s aud, normalized to a list whether the token carried a
single string or an array (non-string entries are dropped). On a
validated token at least one entry is a configured audience. Empty on a
token built with AuthorizedToken::new.
expires_at: SystemTimeThe token’s exp. A validated token was not expired at the moment of
validation (within the configured leeway), so a long-lived connection,
such as an SSE stream, can close itself at this instant. A fractional
exp is rounded to whole seconds exactly as the validation rounded it, and
one later than 9999-12-31T23:59:59Z saturates to that instant. A token built
with AuthorizedToken::new gets 2100-01-01T00:00:00Z.
issued_at: Option<SystemTime>The token’s iat, when it carried a valid NumericDate. Checked only
when crate::OAuthConfig::max_token_age_secs is set. Rounded and saturated like
expires_at; None when iat is absent or not a
non-negative number (the token is still accepted then). None on a token built with AuthorizedToken::new.
client_id: Option<String>The OAuth client the token was issued to: client_id (RFC 9068 §2.2),
else azp, the first that is a non-empty string. The
crate::OAuthConfig::allowed_client_ids check reads the same claims
more strictly: there, a client_id that is present but empty or not a
string refuses the token instead of falling through to azp. None when the token
carries neither, and on a token built with AuthorizedToken::new.
jti: Option<String>The token’s jti, when it carried one as a non-empty string. None on
a token built with AuthorizedToken::new.
Implementations§
Source§impl AuthorizedToken
impl AuthorizedToken
Sourcepub fn new(
subject: Option<String>,
principal: Option<String>,
scopes: impl IntoIterator<Item = impl Into<String>>,
) -> Self
pub fn new( subject: Option<String>, principal: Option<String>, scopes: impl IntoIterator<Item = impl Into<String>>, ) -> Self
Build a token record from parts, with scopes deduplicated in
first-seen order (blank entries dropped), as a validation produces them.
This verifies nothing — it is a plain value constructor for code that
needs an AuthorizedToken without a validation, chiefly tests that place
one in request extensions. crate::OAuthValidator::validate is the only
source of a token that was actually checked. The struct is
#[non_exhaustive], so a field added later gets a default here rather
than breaking callers.
The fields beyond the three arguments get test-friendly defaults: empty
issuer, audiences and claims, None for
issued_at, client_id and jti, and an expires_at of
2100-01-01T00:00:00Z. The far-future expiry is deliberate: a handler
that closes a stream when the token expires must not see a fixture as
already expired. Set any of them with the with_* builders.
Sourcepub fn claims(&self) -> &Map<String, Value>
pub fn claims(&self) -> &Map<String, Value>
The verified claim set, exactly as the token carried it: every claim the
signature covered, including the ones with their own field here.
Empty for a token built with AuthorizedToken::new unless
with_claims was used.
§Security
Claims are trustworthy only on a token a validation produced; one from
AuthorizedToken::new holds whatever the caller put there. Claim
values can be personal data, so Debug does not print them.
§Examples
use oauth_resource_server::AuthorizedToken;
use serde_json::json;
let token = AuthorizedToken::new(Some("user-1".into()), None, ["api:read"])
.with_claims(json!({"groups": ["admins", "dev"]}).as_object().unwrap().clone());
let in_admins = token.claims()["groups"]
.as_array()
.is_some_and(|g| g.iter().any(|v| v == "admins"));
assert!(in_admins);Sourcepub fn claims_as<T: DeserializeOwned>(&self) -> Result<T, Error>
pub fn claims_as<T: DeserializeOwned>(&self) -> Result<T, Error>
The verified claim set deserialized into your own type, for a typed view
of the claims your application cares about. Unknown claims are ignored
unless your type says otherwise (deny_unknown_fields). The claim map is
cloned to deserialize it, so call this once per request, not per field.
§Errors
serde_json::Error when the claims do not fit T, for example a
required field the token did not carry or one of the wrong type.
§Examples
use oauth_resource_server::AuthorizedToken;
use serde::Deserialize;
use serde_json::json;
#[derive(Deserialize)]
struct Claims {
email: String,
#[serde(default)]
groups: Vec<String>,
}
let token = AuthorizedToken::new(None, None, ["api:read"]).with_claims(
json!({"email": "ada@example.com", "groups": ["admins"]})
.as_object()
.unwrap()
.clone(),
);
let claims: Claims = token.claims_as().unwrap();
assert_eq!(claims.email, "ada@example.com");
assert_eq!(claims.groups, ["admins"]);Sourcepub fn with_claims(self, claims: Map<String, Value>) -> Self
pub fn with_claims(self, claims: Map<String, Value>) -> Self
Sourcepub fn with_issuer(self, issuer: impl Into<String>) -> Self
pub fn with_issuer(self, issuer: impl Into<String>) -> Self
Set issuer, for building a token in a test.
Sourcepub fn with_audiences(
self,
audiences: impl IntoIterator<Item = impl Into<String>>,
) -> Self
pub fn with_audiences( self, audiences: impl IntoIterator<Item = impl Into<String>>, ) -> Self
Set audiences, for building a token in a test.
Sourcepub fn with_expires_at(self, expires_at: SystemTime) -> Self
pub fn with_expires_at(self, expires_at: SystemTime) -> Self
Set expires_at, for building a token in a test.
Sourcepub fn with_issued_at(self, issued_at: SystemTime) -> Self
pub fn with_issued_at(self, issued_at: SystemTime) -> Self
Set issued_at, for building a token in a test.
Sourcepub fn with_client_id(self, client_id: impl Into<String>) -> Self
pub fn with_client_id(self, client_id: impl Into<String>) -> Self
Set client_id, for building a token in a test.
Sourcepub fn has_scope(&self, scope: &str) -> bool
pub fn has_scope(&self, scope: &str) -> bool
Whether the token carries scope (exact, case-sensitive match, RFC 6749
§3.3). The single place that answers the question, so callers never
hand-roll a .iter().any() over scopes.
§Examples
use oauth_resource_server::AuthorizedToken;
let token = AuthorizedToken::new(Some("user-1".into()), None, ["api:read", "api:write"]);
assert!(token.has_scope("api:write"));
assert!(!token.has_scope("API:WRITE"));Sourcepub fn require_scopes(&self, required: &[&str]) -> Result<(), MissingScopes>
pub fn require_scopes(&self, required: &[&str]) -> Result<(), MissingScopes>
Whether the token carries EVERY scope in required (all-of): Ok(()),
or the MissingScopes naming what it lacks. An empty required
passes every token.
The same matching the validator’s own required_scopes check runs, on
the same scopes (every configured scope claim, a
string split on whitespace, an array taken element by element), so a
route asking for a scope here and a validator requiring it agree
exactly: an exact, case-sensitive comparison per scope (RFC 6749 §3.3).
An entry that is blank or contains whitespace can never be carried, so
it is always missing.
This is the per-route or per-operation check on top of the
validator’s floor; the layers’ require_scopes, the axum feature’s
RequireScopes and Scoped extractor, and the mcp feature all run
it. To answer a refusal with a 403 whose challenge names these scopes,
see crate::refusal_for_scopes; ? converts the error into
TokenRejection::InsufficientScope.
§Errors
MissingScopes when at least one entry of required is not among
the token’s scopes.
§Examples
use oauth_resource_server::AuthorizedToken;
let token = AuthorizedToken::new(None, None, ["docs:read"]);
assert!(token.require_scopes(&["docs:read"]).is_ok());
let missing = token.require_scopes(&["docs:read", "docs:write"]).unwrap_err();
assert_eq!(missing.required(), ["docs:read", "docs:write"]);
assert_eq!(missing.missing(), ["docs:write"]);Trait Implementations§
Source§impl Clone for AuthorizedToken
impl Clone for AuthorizedToken
Source§impl Debug for AuthorizedToken
impl Debug for AuthorizedToken
impl Eq for AuthorizedToken
Source§impl<S: Send + Sync> FromRequestParts<S> for AuthorizedToken
Available on crate feature axum only.Extracts the OAuth token an AuthLayer accepted.
impl<S: Send + Sync> FromRequestParts<S> for AuthorizedToken
axum only.Extracts the OAuth token an AuthLayer accepted.
Refuses with the layer’s own 401 and WWW-Authenticate challenge when no
token is in the extensions — an optional or
allow_unauthenticated layer passed the
request through, or the static token was accepted — and with 500 (logged at
error) on a route no AuthLayer covers. Behind nested strict layers the
token may have been inserted by an OUTER layer even when the innermost one
accepted the static token; see the module docs.
§Examples
use axum::{Router, routing::get};
use oauth_resource_server::AuthorizedToken;
async fn subject(token: AuthorizedToken) -> String {
token.subject.unwrap_or_default()
}Source§impl<S: Send + Sync> OptionalFromRequestParts<S> for AuthorizedToken
Available on crate feature axum only.Option<AuthorizedToken>: None when an AuthLayer ran and no OAuth
token is in the extensions (no credential under an
optional or
allow_unauthenticated layer, or the
static token was accepted and no outer strict layer inserted a token — see
nested layers). On a route no AuthLayer covers it
still refuses with 500, logged at error, rather than reading as anonymous.
impl<S: Send + Sync> OptionalFromRequestParts<S> for AuthorizedToken
axum only.Option<AuthorizedToken>: None when an AuthLayer ran and no OAuth
token is in the extensions (no credential under an
optional or
allow_unauthenticated layer, or the
static token was accepted and no outer strict layer inserted a token — see
nested layers). On a route no AuthLayer covers it
still refuses with 500, logged at error, rather than reading as anonymous.