Skip to content

Authentication

A Strategy turns a request into a principal — bearer tokens, OAuth redirects, basic credentials. AuthnGuard runs it and Ctx<P> reads it back.

Authentication answers one question: who is calling. The shape of the answer is up to the Strategy — a JWT in the Authorization header, an OAuth provider redirect, a basic-auth client_id:secret, something app-specific. AuthnGuard<S> runs the strategy on every request its controller covers, returns a 401 when it fails, and attaches the principal to the request so the handler reads it back with Ctx<Principal>.

The framework ships JwtStrategy (JSON Web Tokens), an OAuth2 client, and Argon2id password helpers. Anything else is a Strategy you write yourself.

Terminal window
cargo add nest-rs --features authn

The common case writes no strategy at all. AuthnGuard<S> is generic over the strategy, and the framework ships JwtStrategy — a resource-server app ships two short files — the aliases, and the module that provides them:

crates/features/src/authn/strategy.rs (from the demo)
use nest_rs::authn::JwtStrategy;
use super::Claims;
pub type AuthnStrategy = JwtStrategy<Claims>;
pub type AuthnGuard = nest_rs::authn::AuthnGuard<AuthnStrategy>;
crates/features/src/authn/module.rs (from the demo)
use nest_rs::core::module;
use super::strategy::{AuthnGuard, AuthnStrategy};
#[module(
imports = [nest_rs::authn::AuthnModule::for_root(None)],
providers = [AuthnStrategy, AuthnGuard],
)]
pub struct AuthnModule;

Bind the guard on the controller, and every route under it requires a valid principal:

crates/features/src/users/http/controller.rs
#[controller(path = "/users")]
#[use_guards(AuthnGuard, AuthzGuard)]
pub struct UsersController { /* ... */ }

AuthnGuard attaches the principal to the request before the handler runs. The handler reads it with Ctx<P> — same primitive as any other guard-attached context.

crates/features/src/users/http/controller.rs
use nest_rs::http::Ctx;
async fn me(&self, auth: Ctx<Claims>) -> Json<User> {
Json(self.svc.lookup(auth.sub).await?)
}

Ctx<Claims> is a typed lookup into the request’s extensions; missing the guard means missing the type, and the framework returns 500 — the access graph would already have failed the boot if the controller’s import tree did not provide AuthnGuard.

When the credential is not a JWT — an API key, a session cookie, something app-specific — a strategy implements one async method. It returns the principal on success (Ok), or an AuthError describing why it could not (Err). A strategy never issues a transport response itself — a redirect-style flow (OAuth /authorize) is a plain handler, so authentication stays a pure request → principal mapping.

crates/features/src/authn/strategies/api_key.rs
use nest_rs::authn::{AuthError, PrincipalIdentity, Strategy};
use nest_rs::core::{async_trait, injectable};
use nest_rs::http::poem::Request;
#[injectable]
pub struct ApiKeyStrategy {
#[inject]
svc: Arc<KeysService>,
}
#[async_trait]
impl Strategy for ApiKeyStrategy {
type Principal = ApiKey;
async fn authenticate(&self, req: &mut Request) -> Result<ApiKey, AuthError> {
let key = req.headers().get("x-api-key").and_then(|h| h.to_str().ok());
match key.and_then(|k| self.svc.lookup(k)) {
Some(api_key) => Ok(api_key),
None => Err(AuthError::MissingCredentials),
}
}
}
// Every principal declares its audit identity — the value the framework
// records as `actor_id` on the request span, so denials are attributable.
impl PrincipalIdentity for ApiKey {
fn actor_id(&self) -> Option<String> {
Some(self.owner_id.to_string())
}
}

A custom strategy binds exactly like the shipped one: AuthnGuard<ApiKeyStrategy> behind a type alias, listed in providers, bound with #[use_guards(...)].

You declare actor_id once, here. Reading it back is nest_rs::core::current_actor_id(), anywhere the framework carries work — a service, the data layer, a queue job in another process — and it answers None for an anonymous caller. It is an audit identity and never an authorization input: what a caller may do is the ambient Ability, decided in a guard. See Correlation.

nest_rs::authn::Strategy
async fn authenticate(&self, req: &mut Request) -> Result<Self::Principal, AuthError>;

A strategy maps a request to a principal and nothing else — it never issues a transport response. The two protocol styles both fit:

  • Bearer protocols return Ok(principal) on a valid token, Err(AuthError) on a missing or invalid one — the guard turns the error into a 401 with WWW-Authenticate: Bearer.
  • Redirect protocols (OAuth) keep the redirect out of the strategy: the /authorize endpoint is a plain handler that returns a 302, the browser bounces to the provider and comes back to the callback, and only then does a strategy verify the resulting token.

The same trait serves both shapes because authentication is always a pure request → principal mapping; the guard just runs authenticate and reacts to the Result.

A #[public] route still runs AuthnGuard — but the guard does not reject. If a credential is present and valid, the principal is attached so a downstream policy guard can see who is calling. If nothing is present, or the credential is bad, the request continues anonymously. Visitor-rule policy belongs in the authorization layer, not in AuthnGuard.

One failure #[public] does not absorb: AuthError::Unavailable. An unreachable identity store means the credential was never evaluated, so the request fails closed with a 500 rather than being served as anonymous — otherwise an outage would silently downgrade every authenticated caller to a visitor, and a #[public] route’s visitor rules would decide what they see.

crates/features/src/users/http/controller.rs
#[get("/health")]
#[public]
async fn health(&self) -> &'static str { "ok" }

nest-rs-authn exposes two header extractors so a Strategy does not re-parse them:

  • bearer_token(&req) -> Option<&str> — pulls the token out of Authorization: Bearer <token> after trimming.
  • basic_credentials(&req) -> Option<(String, String)> — decodes Authorization: Basic <base64> and returns (client_id, client_secret), splitting on the first colon (RFC 7617).

Both return None rather than erroring on missing/malformed headers — the strategy decides whether a missing credential is an AuthError or an anonymous request the guard lets through on a #[public] route.

AuthError covers the failure modes a strategy hits: MissingCredentials, InvalidToken, InvalidSignature, InvalidAlgorithm, NotYetValid, Expired, Failed(String) — each a 401 — plus Unavailable(String), which is a 500. Failed collapses to a generic "authentication failed" on the wire so the message never leaks configuration detail.

Unavailable is the odd one deliberately: it means the identity store was unreachable, so the credential was never evaluated. That is an infrastructure failure, not a credential signal — reporting it as a 401 would tell a caller with a perfectly good token to go get another one, and would let an outage read as a wave of authentication failures. It is logged at error and rendered as a 500.

A rejected credential also names why on the challenge, per RFC 6750 §3.1:

WWW-Authenticate: Bearer error="invalid_token", error_description="invalid token"

A request that carried no credentials is told no reason — §3 says a server “SHOULD NOT include an error code or other error information” there, because an unauthenticated probe has learned nothing yet.

A password-login service that wants to refuse without distinguishing “wrong email” from “wrong password” returns CredentialError instead — an opaque "invalid credentials" for any miss. See Password for the timing-safe shape of that path.

  • JWT — a resource server verifies a signed bearer token. HS256 (shared secret) or EdDSA (asymmetric, the resource server holds only the public key).
  • OAuth2 — full authorization-code flow with PKCE, against any provider.
  • Password — Argon2id hashing plus burn_verify for timing-safe rejection.
  • Split deployment — the deployment pattern that lets a single token signer feed several verify-only APIs.

Once the principal is on the request, the authorization layer takes over — see Authorization.

  • Password — Argon2id hashing, and a miss path whose timing tells the caller nothing.
  • JWT — verify a signed bearer token, and hold only the public key on a resource server.
  • OAuth2 — the authorization-code flow with PKCE, and stateless CSRF state.
  • Split deployment — one signer, many verifiers, no RPC across the boundary.
  • Social login — one import activates every configured provider, through an open contract.