@mantlejs/auth
JWT authentication engine for Mantle JS. Provides token issuance, verification, a strategy runner, and hook utilities. Designed to be paired with a strategy package such as @mantlejs/auth-local.
Installation
Section titled “Installation”npm install @mantlejs/auth jsonwebtokenConcepts
Section titled “Concepts”The authentication engine
Section titled “The authentication engine”auth() is a Mantle plugin. It registers an AuthEngine on the application and mounts a built-in authentication service at POST /authentication. The engine is the single source of truth for JWT configuration and strategy dispatch — strategy packages (like auth-local) register themselves into it.
Strategies
Section titled “Strategies”A strategy is an object with a name and an authenticate(data, params) method. The engine calls the matching strategy when POST /authentication arrives with a strategy field in the body. Strategy packages call engine.registerStrategy(strategy) during their own plugin registration.
authenticate('jwt') is a before hook that reads Authorization: Bearer <token> from the request headers, verifies the token, and writes the decoded payload to params.user.
authorizeAgent() is a before hook for capability-scoped agent tokens (see Agent tokens below) — opt-in per route, and never a substitute for authenticate('jwt').
sanitizeUser() is an after hook that strips sensitive fields (password, passwordHash) from service results before they leave the server.
Internal calls
Section titled “Internal calls”Hooks registered with authenticate('jwt') automatically skip when the call has no provider (i.e. calls that originate within the server rather than from HTTP). This lets auth strategies query user services without tripping their own guards.
Quick start
Section titled “Quick start”import { mantle } from "@mantlejs/mantle";import { express } from "@mantlejs/express";import { auth, authenticate, sanitizeUser } from "@mantlejs/auth";import { localStrategy, hashPassword } from "@mantlejs/auth-local";
const app = mantle() .configure(express()) .configure(auth({ secret: process.env.JWT_SECRET! })) .configure(localStrategy());
app.use("/users", new UserService(new UserRepository(app)), { methods: ["find", "get", "create", "patch", "remove"],});
app.service("users").hooks({ before: { create: [hashPassword()], find: [authenticate("jwt")], get: [authenticate("jwt")], patch: [authenticate("jwt")], remove: [authenticate("jwt")], }, after: { all: [sanitizeUser()], },});
app.listen(3030);Register a user
POST /usersContent-Type: application/json
{ "email": "alice@example.com", "password": "hunter2" }Log in
POST /authenticationContent-Type: application/json
{ "strategy": "local", "email": "alice@example.com", "password": "hunter2" }{ "accessToken": "eyJhbGciOiJIUzI1NiJ9...", "refreshToken": "eyJhbGciOiJIUzI1NiJ9...", "user": { "id": 1, "email": "alice@example.com" }}Call a protected endpoint
GET /usersAuthorization: Bearer eyJhbGciOiJIUzI1NiJ9...auth(config)
Section titled “auth(config)”Returns a MantlePlugin. Call via app.configure(auth(config)).
app.configure( auth({ secret: process.env.JWT_SECRET!, // required — signing key expiresIn: "7d", // optional — default "1d" algorithms: ["HS256"], // optional — default ["HS256"] issuer: "my-api", // optional audience: "my-client", // optional }),);Side effects:
- Stores the
AuthEngineatapp.get("auth") - Registers
POST /authenticationviaapp.use("authentication", ...)
AuthConfig
Section titled “AuthConfig”| Field | Type | Default | Description |
|---|---|---|---|
secret |
string |
— | JWT signing secret (required) |
expiresIn |
string | number |
"1d" |
Access-token lifetime. Strings use ms format ("2h", "7d"). Numbers are seconds. |
algorithms |
string[] |
["HS256"] |
Verification algorithms |
issuer |
string |
— | Sets and verifies the iss claim |
audience |
string | string[] |
— | Sets and verifies the aud claim |
refreshExpiresIn |
string | number |
"30d" |
Refresh-token lifetime |
refreshTokenStore |
RefreshTokenStore |
in-memory | Storage for outstanding refresh tokens. Multi-instance deployments (Cloud Run) must inject a shared store — the in-memory default cannot revoke tokens issued by another instance. Use redisRefreshTokenStore from @mantlejs/auth-redis. |
agentTokenStore |
AgentTokenStore |
in-memory | Storage for outstanding agent tokens, enabling revokeAgentToken() before JWT expiry. Same multi-instance caveat as refreshTokenStore. |
authenticate(strategy)
Section titled “authenticate(strategy)”A before hook factory. Returns a hook that authenticates the incoming request.
app.service("messages").hooks({ before: { all: [authenticate("jwt")], },});authenticate("jwt")
Reads Authorization: Bearer <token> from params.headers. On success writes the decoded JWT payload to params.user. On failure throws NotAuthenticated.
Silently skips (passes through) when params.provider is undefined — internal service calls are trusted.
Resolving the full user record — authenticate("jwt", { entity })
By default params.user is just the raw JWT payload ({ sub, iat, exp, ... }). Pass entity to resolve
the actual user record instead:
app.service("messages").hooks({ before: { all: [authenticate("jwt", { entity: "users" })], },});With entity set, the hook calls app.service(entity).get(payload.sub) internally (no provider, so
the target service’s own auth guards are bypassed for this lookup) and writes the result to
params.user. The raw JWT payload moves to params.authPayload instead. If the subject no longer
resolves to a user (e.g. deleted after the token was issued), the hook throws
NotAuthenticated("User for this token no longer exists").
authenticate("custom")
For non-JWT strategies: delegates to engine.authenticate(strategyName, context.data, context.params) and writes the result to params.user. Skips for internal calls.
authorizeAgent()
Section titled “authorizeAgent()”A before hook factory. Returns a hook that authorizes a capability-scoped agent token — see
Agent tokens for issuance and revocation.
app.service("articles").hooks({ before: { all: [authorizeAgent()] },});Reads Authorization: Bearer <token> from params.headers, verifies it, and checks it’s an
unrevoked type: "agent" token whose scope covers HookContext.path+HookContext.method
(@mantlejs/mantle’s matchesCapabilityScope — the same deny-by-default matcher @mantlejs/mcp’s
expose map uses). On success sets HookContext.agent = { id, scope, delegatingUserId }, additive
to params.user. Throws Forbidden for an out-of-scope call, NotAuthenticated for anything else
(missing/expired/malformed/non-agent/revoked token, or auth not configured).
Silently skips (passes through) when params.provider is undefined — internal service calls are
trusted, same convention as authenticate("jwt").
Opt-in per route: authenticate("jwt") rejects a type: "agent" payload outright, so a route with
no authorizeAgent() hook attached can never be reached with an agent token.
sanitizeUser(fields?)
Section titled “sanitizeUser(fields?)”An after hook that removes sensitive fields from the service result. Works with single objects, arrays, and paginated results.
app.service("users").hooks({ after: { all: [sanitizeUser()], // removes password, passwordHash, password_hash get: [sanitizeUser(["secret", "apiKey"])], // custom field list },});Default fields removed: "password", "passwordHash", "password_hash".
AuthEngine (advanced)
Section titled “AuthEngine (advanced)”Accessed via app.get<AuthEngine>("auth"). Useful when writing custom strategy packages.
const engine = app.get<AuthEngine>("auth");
// Create a tokenconst token = engine.createJwt({ sub: String(user.id), role: "admin" });
// Verify a tokenconst payload = engine.verifyJwt(token); // throws if invalid
// Register a custom strategyengine.registerStrategy({ name: "magic-link", async authenticate(data, params) { // verify magic link token, find user, issue JWT... return { accessToken: engine.createJwt({ sub: userId }) }; },});POST /authentication
Section titled “POST /authentication”The built-in authentication endpoint. Dispatches to a registered strategy based on the strategy field.
Request
{ "strategy": "local", "email": "alice@example.com", "password": "hunter2"}Response (201 Created)
{ "accessToken": "eyJhbGciOiJIUzI1NiJ9...", "refreshToken": "eyJhbGciOiJIUzI1NiJ9...", "user": { "id": 1, "email": "alice@example.com" }}Errors
| Status | Condition |
|---|---|
400 Bad Request |
strategy field missing |
400 Bad Request |
Strategy name not registered |
401 Not Authenticated |
Strategy-specific failure (wrong password, user not found, etc.) |
Refresh tokens
Section titled “Refresh tokens”Every login (local or OAuth) returns an access + refresh token pair issued by engine.createTokenPair(sub). The
refresh token carries { sub, type: "refresh", jti }, lives refreshExpiresIn (default 30 days), and its jti
is recorded in the RefreshTokenStore.
Exchange it for a fresh pair via the built-in refresh strategy — same endpoint, no extra route:
POST /authentication{ "strategy": "refresh", "refreshToken": "eyJhbGciOiJIUzI1NiJ9..." }Response (201 Created): { "accessToken": "...", "refreshToken": "..." } — no user field; a refresh
proves possession of a token, not fresh credentials.
Refresh tokens rotate: each successful exchange consumes the submitted token and issues a new one. Replaying
an already-consumed token is treated as theft — all outstanding refresh tokens for that subject are revoked
and the request fails with 401 NotAuthenticated("Refresh token reuse detected"). Expired tokens, access tokens
submitted as refresh tokens, and unknown signatures all fail with 401 NotAuthenticated("Invalid refresh token").
Custom strategies should issue tokens through engine.createTokenPair(sub, accessExtra?) rather than
createJwt directly, so their refresh tokens participate in rotation and revocation.
Agent tokens
Section titled “Agent tokens”Short-lived, capability-scoped tokens for AI-agent callers, delegated from a user rather than
representing one. Distinct from user JWTs and refresh tokens — authenticate("jwt") rejects an
agent token outright, and authorizeAgent() rejects anything that isn’t one.
Issuing a token — typically from your own route/service, after checking the delegating user is allowed to grant the requested scope:
const engine = app.get<AuthEngine>("auth");const issued = await engine.issueAgentToken( { articles: ["find", "get"], comments: true }, // CapabilityScope: methods per path, or `true` for all user.id, // delegatingUserId { expiresIn: "15m" }, // optional, default "15m");// issued: { accessToken, id, expiresAt }Authorizing agent calls — attach authorizeAgent() to any route an agent may call. It’s opt-in
per route: a route with no authorizeAgent() hook can never be reached with an agent token, no
matter its scope, because authenticate("jwt") refuses type: "agent" payloads.
app.service("articles").hooks({ before: { all: [authorizeAgent()] },});On success it sets HookContext.agent = { id, scope, delegatingUserId } — additive, params.user
is untouched. On an out-of-scope path+method it throws Forbidden; on a missing, expired,
malformed, non-agent, or revoked token it throws NotAuthenticated.
Revoking a token before its natural expiry:
await engine.revokeAgentToken(issued.id);Revocation is tracked in the AgentTokenStore (default: in-memory). Multi-instance deployments
must inject a shared store — same rationale as refreshTokenStore above.
import type { AgentPrincipal, AgentTokenOptions, AgentTokenStore, AuthConfig, AuthEngine, AuthResult, AuthStrategy, IssuedAgentToken, JwtPayload, RefreshTokenStore, TokenPair,} from "@mantlejs/auth";| Type | Description |
|---|---|
AuthConfig |
Options passed to auth() |
AuthEngine |
The engine stored at app.get("auth") |
AuthResult |
{ accessToken: string; [key: string]: unknown } — returned by strategies |
AuthStrategy |
Interface to implement for custom strategies |
JwtPayload |
Decoded JWT payload shape |
RefreshTokenStore |
add(jti, sub, exp) / consume(jti) / revokeAll(sub) — inject a shared implementation (e.g. @mantlejs/auth-redis) for multi-instance deployments |
TokenPair |
{ accessToken, refreshToken } — returned by engine.createTokenPair() |
AgentPrincipal |
{ id, scope: CapabilityScope, delegatingUserId } — an agent token’s decoded shape, and HookContext.agent’s type once authorizeAgent() accepts it |
AgentTokenOptions |
{ expiresIn? } — passed to engine.issueAgentToken() |
AgentTokenStore |
add(id, exp) / isValid(id) / revoke(id) — inject a shared implementation for multi-instance deployments |
IssuedAgentToken |
{ accessToken, id, expiresAt } — returned by engine.issueAgentToken() |
Writing a custom strategy
Section titled “Writing a custom strategy”import type { MantlePlugin } from "@mantlejs/mantle";import type { AuthEngine, AuthStrategy } from "@mantlejs/auth";
export function magicLinkStrategy(): MantlePlugin { return (app) => { const engine = app.get<AuthEngine>("auth"); if (!engine) throw new Error("@mantlejs/auth must be configured first");
const strategy: AuthStrategy = { name: "magic-link", async authenticate(data) { // 1. Validate data.token against your store // 2. Look up the user // 3. Return the JWT const accessToken = engine.createJwt({ sub: String(userId) }); return { accessToken, user }; }, };
engine.registerStrategy(strategy); };}Register it alongside your other plugins:
app.configure(auth({ secret: process.env.JWT_SECRET! })).configure(magicLinkStrategy());Error reference
Section titled “Error reference”All errors extend MantleError and serialize to JSON automatically.
| Error | Code | When thrown |
|---|---|---|
NotAuthenticated |
401 | Missing/invalid/expired Bearer token |
BadRequest |
400 | strategy field missing from POST /authentication |
BadRequest |
400 | Named strategy not registered |
Development
Section titled “Development”npx nx build auth # compilenpx nx test auth # run testsnpx nx lint auth # lintPublishing
Section titled “Publishing”Build before publishing:
npx nx build authFirst publish (scoped packages require --access public):
cd packages/authnpm publish --access publicSubsequent releases — bump version in packages/auth/package.json, then:
cd packages/authnpm publishTesting locally with Verdaccio
Section titled “Testing locally with Verdaccio”# Terminal 1 — start the local registrynpx nx run @mantle/source:local-registry
# Terminal 2 — publish to itcd packages/authnpm publish --registry http://localhost:4873
# Install from it in another projectnpm install @mantlejs/auth --registry http://localhost:4873