Auth
The Auth capability answers one question per request: who is the caller? An Auth plugin takes a request, looks at whatever credential it carries — a JWT in the Authorization header, a session cookie, an OAuth callback — and returns either an AuthUser or nothing at all. What it does not do is store sessions. That is the Session capability's job, and the split is deliberate: it lets you mix a JWT strategy with a Redis session store, or magic-link auth with cookie-only sessions, without either plugin knowing about the other.
This page covers the trait surface, the kinds of provider an Auth plugin can be, and how the reference @bext/auth-jwt plugin uses the shape to validate bearer tokens.
The trait
Every Auth plugin implements one trait, AuthPlugin, defined in bext-plugin-api::auth. It has one required method, three optional ones, and a shape for the data it passes in and out:
pub trait AuthPlugin: Send + Sync {
fn name(&self) -> &str;
fn provider_kind(&self) -> AuthProviderKind;
fn resolve(&self, ctx: &AuthRequestContext)
-> Result<Option, String>;
fn begin_login(&self, ctx: &AuthRequestContext, params: LoginParams)
-> Result<LoginAction, String> { /* default: not supported */ }
fn complete_login(&self, ctx: &AuthRequestContext, callback: CallbackData)
-> Result<AuthUser, String> { /* default: not supported */ }
fn logout(&self, ctx: &AuthRequestContext) -> Result<(), String> {
Ok(())
}
}
Only name, provider_kind, and resolve must be implemented. A validation-only backend like @bext/auth-jwt keeps the other three as defaults, because there is no interactive login flow to run — the token either validates or it does not. A full OAuth or magic-link plugin overrides all four.
resolve returns Ok(None) for "not logged in" — that is the expected shape of an anonymous request, not an error. Only malformed credentials or a backend failure should return Err(..), which the runtime surfaces as 401 or 500.
AuthUser
The return type of a successful resolve is AuthUser, and the field set is deliberately small:
pub struct AuthUser {
pub subject: String,
pub tenant_id: Option,
pub role: String,
pub permissions: Vec,
pub attributes: HashMap<String, String>,
}
- subject is the stable unique id of the user within that provider.
- tenant_id is the tenant scope on multi-tenant deployments.
- role is the primary role name, permissions is the fine-grained list.
- attributes is the escape hatch for provider-specific claims — OAuth ID-token extras, SAML attributes, custom claims — so the trait itself never grows vendor-specific fields.
There is no email, no display_name, no avatar_url. Those live in attributes, because not every provider has them and the shape is otherwise different enough that elevating any one of them would just be elevating one vendor's shape over everyone else's.
Provider kinds
An Auth plugin tells the runtime what shape of flow it implements with AuthProviderKind:
| Kind | When to use | Implements login flow? |
|---|---|---|
Token |
Stateless JWT, opaque bearer, API keys | No |
Password |
Username + password against a store | Yes |
OAuth |
Redirect-based OAuth 2 / OIDC | Yes |
MagicLink |
One-time link sent to email or SMS | Yes |
Passkey |
WebAuthn / passkeys — finishes in a single call | Yes |
The runtime uses this to decide which HTTP routes to mount. An OAuth plugin needs a /auth/callback route; a token plugin does not. Stateless providers skip the login-flow methods entirely and just validate whatever credential is on each request.
How @bext/auth-jwt uses the shape
@bext/auth-jwt is the reference Auth plugin for stateless token validation. It implements AuthPlugin in about a hundred lines:
1. provider_kind returns AuthProviderKind::Token.
2. resolve pulls a token from the request — Authorization header first, then a named cookie — and runs it through jsonwebtoken::decode. A valid token becomes an AuthUser; a malformed token returns an error; a missing token returns Ok(None).
3. begin_login, complete_login, and logout keep their defaults. There is no interactive flow for bearer tokens — the token either came in on the request or it did not.
The JWT claims map to AuthUser like this:
- sub → subject
- tenant_id (or tenantId) → tenant_id
- roles[0] (or role) → role
- permissions → permissions
- Everything else stays in the JWT untouched (no leakage into attributes unless the plugin is configured to copy specific claims).
Both HS256 (shared secret) and RS256 (pre-parsed PEM public key) are supported. JWKS fetching is deliberately out of scope for the reference plugin — operators who want it should pre-fetch at startup and hand the decoded PEM to JwtAuthPlugin::new_rs256_pem. Fetching inside the plugin would tie the reference implementation to an HTTP client, and the point of a reference plugin is to be the smallest thing that exercises the shape.
Constructing a plugin
The plugin has two constructors and one modifier, all sync:
use bext_auth_jwt::JwtAuthPlugin;
// HS256 with a shared secret
let plugin = JwtAuthPlugin::new_hs256("your-shared-secret");
// RS256 with a pre-parsed public key
let plugin = JwtAuthPlugin::new_rs256_pem(pem_bytes)?;
// Override the cookie name (default: "next-auth.session-token")
let plugin = JwtAuthPlugin::new_hs256(secret)
.with_cookie_name("my-session");
Configuring Auth in bext.config.toml
You never call an auth plugin directly from application code. You install it, point [auth] at it in config, and the runtime does the wiring:
[auth]
provider = "jwt"
[auth.jwt]
# HS256 with a shared secret (development)
secret = "env:BEXT_AUTH_JWT_SECRET"
# Or RS256 with a PEM file (production)
# public_key_pem = "/etc/bext/jwt-public.pem"
# Optional: where to read the token from
cookie_name = "next-auth.session-token"
[auth] picks the active provider; [auth.jwt] carries the plugin-specific settings. Other plugins declare requires_capabilities = ["auth"] in their manifest and use the host-function API to read the current user — no plugin ever touches a JWT directly.
Auth depends on Session
Installing @bext/auth-jwt is fine on its own, because bearer-token auth is stateless and does not need a session store. But most interactive providers — OAuth, magic-link, passkeys — do need somewhere to persist the proof that a login happened. That is the Session capability, and every interactive Auth plugin declares requires_capabilities = ["session"] in its manifest.
When you install an Auth plugin through the CLI, the registry resolver reads the requirement and walks you through picking a Session backend if you do not already have one configured. You will not end up with an OAuth plugin that has nowhere to store its sessions, because the resolver catches that at install time.
The runtime wiring for an interactive login flow looks like this:
1. User hits a protected route without a session. The runtime calls begin_login on the active Auth plugin; the plugin returns a LoginAction::Redirect (OAuth), LoginAction::Prompt (password, magic-link), or LoginAction::Complete (passkeys).
2. User returns via the callback route. The runtime calls complete_login with the callback data. The plugin returns an AuthUser on success.
3. The runtime hands the user to the active Session plugin, which issues a session and returns an id. The id goes in a cookie.
4. On subsequent requests, resolve reads the session id from AuthRequestContext::session_id, looks the session up via the Session capability, and returns the user.
The Auth plugin never persists session state itself. That separation is the whole reason Auth and Session are two capabilities instead of one.
Where to go next
- Capabilities overview — how the capability promotion ladder works, and why the foundational five ship together.
- Session capability — the other half of the login story, and where session state actually lives.
- Plugin system overview — the sandbox tiers (WASM, QuickJS, nsjail) an Auth plugin can run inside.
- Building a plugin — scaffolding a plugin and declaring provides_capabilities = ["auth"] in the manifest.