pub struct AuthLayerBuilder { /* private fields */ }axum only.Expand description
Builder for an enforcing AuthLayer; see AuthLayer::builder.
Implementations§
Source§impl AuthLayerBuilder
impl AuthLayerBuilder
Sourcepub fn static_token(self, token: impl Into<String>) -> Self
pub fn static_token(self, token: impl Into<String>) -> Self
Accept this static token (compared in constant time). An empty string counts as no token.
§Security
Setting the token here bypasses accept_static_bearer, which only
crate::static_token_policy reads; with OAuth configured, prefer
AuthLayerBuilder::build_with_decision. The token’s length is not
hidden by the comparison, and Debug output shows it as <redacted>.
Sourcepub fn optional_static_token(self, token: Option<String>) -> Self
pub fn optional_static_token(self, token: Option<String>) -> Self
AuthLayerBuilder::static_token when Some; for threading through the
output of crate::static_token_policy or a secret loader.
Sourcepub fn static_tokens(self, tokens: StaticTokens) -> Self
pub fn static_tokens(self, tokens: StaticTokens) -> Self
Accept every token in tokens — several static API keys at once, for
a zero-downtime key rotation or one key per client — replacing a set
given earlier. Each request’s candidates are compared with every
entry in constant time (see StaticTokens). On a match the request’s
extensions get Credential::StaticToken, exactly as for a single
static_token, and the StaticTokenMatch
naming the entry’s label, which a handler reads with the
StaticTokenMatch extractor.
How it combines with the other static-token settings:
- With
static_token: both are accepted. A secret given both ways counts once, under the set’s label. - With
build_with_decision(orAuthLayer::from_decision, which takes no set): the set follows the decision — see that method. - An empty set counts as no static token, as an empty
static_tokendoes: it never satisfies the fail-closed build on its own.
A one-entry set behaves exactly like static_token
with that secret (same statuses, challenges, bodies and
Credential); only the StaticTokenMatch it inserts, unlabeled
or not, is new — and a layer built with static_token inserts an
unlabeled one too.
§Security
Setting tokens here bypasses accept_static_bearer unless the layer
is built with build_with_decision.
Debug output shows the count and labels, never a secret.
§Examples
use axum::{Router, routing::get};
use oauth_resource_server::StaticTokenMatch;
use oauth_resource_server::StaticTokens;
use oauth_resource_server::axum::AuthLayer;
async fn which_key(matched: Option<StaticTokenMatch>) -> String {
match matched {
Some(m) => format!("key {}", m.label().unwrap_or("(unlabeled)")),
None => "not a static key".to_string(),
}
}
let tokens = StaticTokens::new()
.with(Some("current"), "example-key-old")
.and_then(|t| t.with(Some("next"), "example-key-new"))
.unwrap();
let auth = AuthLayer::builder().static_tokens(tokens).build().unwrap();
let app: Router = Router::new().route("/", get(which_key)).route_layer(auth);Sourcepub fn optional_static_tokens(self, tokens: Option<StaticTokens>) -> Self
pub fn optional_static_tokens(self, tokens: Option<StaticTokens>) -> Self
AuthLayerBuilder::static_tokens when Some — for threading through
the output of the env feature’s static_tokens_from_env; None
clears a set given earlier.
Sourcepub fn oauth(self, validator: Arc<OAuthValidator>) -> Self
pub fn oauth(self, validator: Arc<OAuthValidator>) -> Self
Accept OAuth access tokens this validator accepts.
Sourcepub fn optional_oauth(self, validator: Option<Arc<OAuthValidator>>) -> Self
pub fn optional_oauth(self, validator: Option<Arc<OAuthValidator>>) -> Self
AuthLayerBuilder::oauth when Some.
Sourcepub fn sources(
self,
sources: impl IntoIterator<Item = CredentialSource>,
) -> Self
pub fn sources( self, sources: impl IntoIterator<Item = CredentialSource>, ) -> Self
Where to read credentials from, replacing the default
[CredentialSource::authorization_bearer()]. Every source is checked,
whatever the others hold.
Sourcepub fn static_challenge(self, challenge: Option<HeaderValue>) -> Self
pub fn static_challenge(self, challenge: Option<HeaderValue>) -> Self
The WWW-Authenticate challenge every 401 carries when NO OAuth
validator is configured; with one, the validator’s challenges are used
and this is ignored. Default: DEFAULT_STATIC_CHALLENGE.
Some(value) sends value instead — Bearer realm="my-api", say, or
your own scheme for an API-key header. None sends no challenge at all
and leaves any WWW-Authenticate an on_reject
callback set untouched. That departs from RFC 9110 §15.5.2 (a 401 MUST
carry a challenge); use it only to keep an existing API’s responses
unchanged.
use axum::http::HeaderValue;
use oauth_resource_server::axum::AuthLayer;
let auth = AuthLayer::builder()
.static_token("example-static-key")
.static_challenge(Some(HeaderValue::from_static("Bearer realm=\"my-api\"")))
.build()
.unwrap();Sourcepub fn optional(self) -> Self
pub fn optional(self) -> Self
Let a request that presents NO credential through, unauthenticated, with nothing inserted into its extensions; everything else is decided exactly as without this. For routes that serve everyone but personalize for an authenticated caller, or read-open/write-authenticated APIs.
- A credential that is accepted is inserted as usual.
- A credential that is presented but refused — invalid, expired, signed
by an unknown key, not the static token, or a valid token without the
required scopes — gets the same 401 or 403, with the same
WWW-Authenticatechallenge andon_rejectbody, as withoutoptional(). A client with a bad token learns so, rather than being served silently as anonymous. - A request presents no credential when EVERY value of EVERY configured
CredentialSourceheader is absent or blank: empty or whitespace for aCredentialSource::Rawheader; for aCredentialSource::Bearerheader, empty or whitespace afterBearer, or a value using some other scheme (which carries no bearer credential to check). This is the same classificationcrate::authenticate()reports asTokenRejection::Missing, with two stricter edges: a header value that is not visible ASCII counts as a presented credential, not as a blank one, and so does a non-blank LATER value of a repeated header (only the first is ever authenticated). Both are refused with the layer’s 401. So are aBearervalue that uses a tab instead of a space before a non-blank token, and anyDPoP-scheme value (RFC 9449; a sender-constrained token this crate cannot verify must be refused, not served as anonymous). The strict parsing of those values is unchanged: they get exactly the refusal a non-optional layer sends. - Any
Credential/AuthorizedTokenan outer layer inserted is removed first, so a pass-through always extracts asNone(see nested layers).
Handlers read the outcome with Option<Credential> or
Option<AuthorizedToken> (see the module docs); a
handler that takes a plain Credential or AuthorizedToken refuses a
passed-through request with the layer’s own 401 and challenge.
§Security
This is not a way around the fail-closed build: build
still requires a static token or an OAuth validator, and a credential
the layer cannot accept is still refused. What passes through is only
what any caller could send by leaving the credential headers off, so
every handler behind an optional layer must treat None as
unauthenticated. The pass-through is logged at debug.
§Examples
use axum::{Router, routing::get};
use oauth_resource_server::Credential;
use oauth_resource_server::axum::AuthLayer;
async fn greeting(credential: Option<Credential>) -> &'static str {
match credential {
Some(_) => "hello, authenticated caller",
None => "hello, anonymous caller",
}
}
let auth = AuthLayer::builder()
.static_token("example-static-key")
.optional()
.build()
.unwrap();
let app: Router = Router::new().route("/", get(greeting)).route_layer(auth);Sourcepub fn require_scopes(
self,
scopes: impl IntoIterator<Item = impl Into<String>>,
) -> Self
pub fn require_scopes( self, scopes: impl IntoIterator<Item = impl Into<String>>, ) -> Self
Require every scope in scopes (all-of) of every credential this
layer accepts, on top of the validator’s own required_scopes —
replacing scopes given earlier. It runs after validation, on the same
validator and so the same key cache: a layer for the routes that
write needs no second validator, only a second layer built from the
same Arc<OAuthValidator> (or, simpler, a RequireScopes route
layer, or the Scoped extractor, behind this one).
- An OAuth token missing one is refused with 403 and a challenge
naming the validator’s required scopes followed by these — the
scopes this request needs, which is what a client re-authorizes for
(
crate::refusal_for_scopessends the same bytes). - A static token has no scopes, so it is refused the same way, with
403, unless
static_token_bypasses_scopes. Fail-safe by default: in dual mode a static token was never subject to the validator’s scopes, but a route that asks for scopes by name asks for them of every credential. - An
optionallayer still passes a request that presents no credential (its handlers must treat that as unauthenticated); a presented one is checked. - A layer that lets everything through
(
AuthLayer::allow_unauthenticated) checks nothing, this included, sobuild_with_decisionrefuses anUnauthenticateddecision on a builder with scopes (AuthLayerError::ScopesWithoutAuthentication) rather than silently drop them.
build refuses an entry that is not an RFC 6749 §3.3
scope-token (AuthLayerError::InvalidScope), and scopes with no
OAuth validator unless static tokens bypass them
(AuthLayerError::ScopesNeedOAuth).
§Examples
use std::sync::Arc;
use axum::{Router, routing::{get, post}};
use oauth_resource_server::OAuthValidator;
use oauth_resource_server::axum::AuthLayer;
let reads = AuthLayer::builder().oauth(Arc::clone(&oauth)).build().unwrap();
// The same validator: one key cache for both.
let writes = AuthLayer::builder()
.oauth(oauth)
.require_scopes(["docs:write"])
.build()
.unwrap();
Router::new()
.merge(Router::new().route("/docs", get(|| async { "read" })).route_layer(reads))
.merge(Router::new().route("/docs/new", post(|| async { "written" })).route_layer(writes))Sourcepub fn static_token_bypasses_scopes(self) -> Self
pub fn static_token_bypasses_scopes(self) -> Self
Let a static token pass require_scopes
instead of refusing it with 403: the static token counts as holding
every scope, as it did before per-route scopes existed. Without
require_scopes it changes nothing.
§Security
Opt in only where the static token is meant to be a full-access key.
Sourcepub fn on_reject(
self,
f: impl Fn(RejectContext<'_>) -> Response + Send + Sync + 'static,
) -> Self
pub fn on_reject( self, f: impl Fn(RejectContext<'_>) -> Response + Send + Sync + 'static, ) -> Self
Build the body and any extra headers of a refusal — for an API whose
errors are, say, JSON. Without it a refusal has an empty body. The
RejectContext carries the rejection, the status and the request’s
parts (method, URI, headers), so the shape can depend on, say, Accept.
The callback shapes the response only; it cannot change the outcome.
Whatever it returns, the status is set to RejectContext::status (401
for a missing or invalid credential, 403 for insufficient scope), and
WWW-Authenticate is set to the layer’s challenge, replacing any the
callback set: the validator’s when OAuth is configured — every 401/403
must carry it, or claude.ai (among others) never starts the
authorization flow — and otherwise the
static_challenge. Only with
static_challenge(None) and no OAuth are the callback’s headers left
as they are.
Never put TokenRejection::Invalid’s reason in the body: telling an
unauthenticated caller exactly which check failed is a free oracle. The
credential headers in RejectContext::request are marked sensitive,
and RejectContext’s Debug prints no header values.
Sourcepub fn build_with_decision(
self,
decision: StaticTokenDecision,
) -> Result<AuthLayer, AuthLayerError>
pub fn build_with_decision( self, decision: StaticTokenDecision, ) -> Result<AuthLayer, AuthLayerError>
Build the layer a crate::static_token_policy decision calls for,
keeping this builder’s sources and on_reject.
The decision’s static token (if any) replaces one set on this builder.
StaticTokenDecision::Unauthenticated yields
AuthLayer::allow_unauthenticated — the decision is itself the
application’s explicit opt-out, since static_token_policy returns it
only when asked to allow unauthenticated access. Every other decision
builds an enforcing layer exactly as AuthLayerBuilder::build does.
A static_tokens set follows the decision,
which speaks about one token (pass the current one to
static_token_policy):
| Decision | The builder’s static_tokens set |
|---|---|
StaticOnly(t), StaticAndOAuth(t) | kept, and merged with t (a t already in the set counts once, under its label) |
StaticIgnored | dropped, with t: accept_static_bearer: false wins over every static token |
OAuthOnly, Unauthenticated | AuthLayerError::DecisionWithoutStaticToken — the policy was never told a static token exists, so the decision cannot speak for the set, and honouring it would silently drop the keys (or open the routes despite them) |
An empty set is no set: it never causes that error.
§Errors
The decision must agree with the validator given via
AuthLayerBuilder::oauth: AuthLayerError::DecisionNeedsOAuth when
it was made with OAuth on and no validator was given,
AuthLayerError::DecisionWithoutOAuth when it was made with OAuth off
(including Unauthenticated) and one was given. Then
AuthLayerError::DecisionWithoutStaticToken as above, then
AuthLayerError::ScopesWithoutAuthentication for
require_scopes with an Unauthenticated
decision. Otherwise as AuthLayerBuilder::build.
Sourcepub fn build(self) -> Result<AuthLayer, AuthLayerError>
pub fn build(self) -> Result<AuthLayer, AuthLayerError>
Build the layer.
§Errors
AuthLayerError::NoCredential with neither a non-blank static token
(from static_token or a non-empty
static_tokens set) nor an OAuth validator;
AuthLayerError::NoSources with an empty
source list; AuthLayerError::InvalidChallenge when the validator’s
challenge is not a valid header value (only reachable from a
hand-edited resolved config); AuthLayerError::InvalidScope and
AuthLayerError::ScopesNeedOAuth as
require_scopes says.