Skip to content

Authentication

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. AuthGuard<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-authn

The common case writes no strategy at all. AuthGuard<S> is generic over the strategy, and the framework ships JwtStrategy — a resource-server app ships a single type alias and a tiny module:

crates/features/src/authn/mod.rs
pub type AppJwtStrategy = JwtStrategy<Claims>;
pub type AuthGuard = nest_rs_authn::AuthGuard<AppJwtStrategy>;
#[module(
imports = [nest_rs_authn::AuthnModule::for_root(None)],
providers = [AppJwtStrategy, AuthGuard],
)]
pub struct AuthnModule;

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

#[controller(path = "/users")]
#[use_guards(AuthGuard, AuthzGuard)]
pub struct UsersController { /* ... */ }

AuthGuard 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.

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 AuthGuard.

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.

use async_trait::async_trait;
use nest_rs_authn::{AuthError, PrincipalIdentity, Strategy};
use 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: AuthGuard<ApiKeyStrategy> behind a type alias, listed in providers, bound with #[use_guards(...)].

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 AuthGuard — 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 fails, the request continues anonymously. Visitor-rule policy belongs in the authorization layer, not in AuthGuard.

#[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 simply 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 maps to a 401 body when returned by a Strategy; Failed collapses to a generic "authentication failed" on the wire so the message never leaks configuration detail.

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.
  • Issuer and resource server — 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.