Authentication & Authorization
[Authorize] lives on routes and controllers.MasterController’s auth mirrors ASP.NET Core: schemes establish who the caller is (JWT bearer, signed cookie, or your own), policies decide what they may do, authorization is declared on routes and controllers, and the caller is a ClaimsPrincipal on this.user. No extra dependencies — JWT and the password hasher use node:crypto.
| ASP.NET Core | MasterController |
|---|---|
AddAuthentication().AddJwtBearer(...) | master.auth.addJwtBearer(name, { secret | publicKey, issuer, audience }) |
AddAuthentication().AddCookie(...) | master.auth.addCookie(name, { secret, loginPath, mode: 'web' | 'api' }) |
custom AuthenticationHandler | master.auth.addScheme(name, { type: 'custom', authenticate(requestObject) }) |
AddAuthorization(o => o.AddPolicy(...)) | master.auth.addPolicy(name, { roles } | { claims } | (user, ctx) => bool) |
[Authorize], [Authorize(Roles/Policy)] | route { authorize: true | 'Policy' | { roles, claims, schemes } }, controller static authorize |
[AllowAnonymous] | route { allowAnonymous: true }, controller static allowAnonymous = true | ['index'] |
HttpContext.User | this.user / requestObject.user |
SignInAsync / SignOutAsync | await this.signIn(claims, { scheme }) / await this.signOut({ scheme }) |
PasswordHasher<TUser> | master.auth.passwordHasher.hash(pw) / verify(pw, stored) (scrypt) |
UseAuthentication() | registered automatically before routing — it only resolves the user, never blocks |
Setup: schemes and policies#
// server.js — after master.setupServer()
master.auth.addJwtBearer('bearer', {
secret: process.env.JWT_SECRET, // >= 32 chars (HS256); or publicKey + algorithm: 'RS256' | 'ES256' | 'PS256'
issuer: 'my-api', audience: 'my-app',
expiresIn: '1h', // default for issueToken()
});
master.auth.addCookie('cookie', {
secret: [process.env.COOKIE_SECRET, process.env.COOKIE_SECRET_PREVIOUS], // rotation: sign with the first, accept all
mode: 'web', loginPath: '/login', accessDeniedPath: '/denied', // 'api' (default) answers 401/403 JSON instead
maxAgeMs: 14 * 24 * 3600 * 1000, slidingExpiration: true, sameSite: 'lax',
});
master.auth.setDefaultScheme('bearer'); // DefaultAuthenticateScheme (first registered by default)
master.auth.addPolicy('AdminOnly', { roles: ['admin'] }); // any of the roles
master.auth.addPolicy('Verified', { claims: { email_verified: true } }); // all claims must match
master.auth.addPolicy('Senior', (user, requestObject) => Number(user.findFirst('level')) >= 5);- JWT bearer — HS256/384/512 with
secret, or RS/PS/ES256/384/512 withpublicKey(or akeyResolver) andalgorithm. Verification enforces the algorithm allow-list (noalg: none),exp/nbfwith 60 s tolerance, andiss/audwhen configured. - Cookie — HMAC-SHA256 signed, verified in constant time;
secretmay be an array for rotation.mode: 'api'(default) answers 401/403 JSON,mode: 'web'redirects tologinPath?returnUrl=…/accessDeniedPath.HttpOnly,SameSiteandSecure('auto'on TLS) are set for you. - Policies —
{ roles: [...] }passes with any role,{ claims: {...} }requires all claims, a function gets(user, requestObject).
Protecting routes and controllers#
// app/routes.js
router.route('/admin/reports', 'reports#index', 'get', { authorize: { roles: ['admin'] } });
router.route('/me', 'account#me', 'get', { authorize: true }); // any authenticated user
router.route('/billing', 'billing#index', 'get', { authorize: 'Verified' }); // policy
router.route('/health', 'health#index', 'get', { allowAnonymous: true });
router.route('/partner', 'partner#index', 'get', { authorize: { schemes: ['apiKey'] } }); // a specific scheme
router.resources('posts', { authorize: true, except: ['index', 'show'] }); // only/except supported// app/controllers/postsController.js
export default class PostsController {
static authorize = { '*': true, index: false, show: false }; // per action; '*' is the default
static allowAnonymous = ['health']; // alternative opt-out list
async destroy(obj) {
if (!(await this.authorize('AdminOnly'))) return; // in-action check: writes 401/403 itself
// ...
}
async me() { this.returnJson({ id: this.user.id, name: this.user.name, roles: this.user.roles }); }
}Order of enforcement: route-level authorize → controller-level static authorize → your beforeAction filters → the action. A route with allowAnonymous: true skips the controller-level requirement. Route groups and minimal APIs accept the same authorize / allowAnonymous options.
Failures are ProblemDetails#
Unauthenticated → 401 as application/problem+json with WWW-Authenticate (error="invalid_token" when a bad token was sent); authenticated but not allowed → 403. Cookie schemes in mode: 'web' redirect instead. await this.authorize(spec) in an action writes the same responses itself and returns false; this.isAuthorized(spec) only evaluates.
Signing in and out#
export default class SessionController {
async login(obj) {
const { email, password } = obj.params.formData;
const user = await this.db.User.where('u => u.email == $$', email).single();
const { valid, needsRehash } = user ? await master.auth.passwordHasher.verify(password, user.passwordHash) : { valid: false };
if (!valid) return this.returnError(401, 'Invalid credentials');
if (needsRehash) { user.passwordHash = await master.auth.passwordHasher.hash(password); await this.db.saveChanges(); }
// API clients: issue a JWT
const { token } = await this.signIn({ sub: user.id, name: user.name, roles: user.roles }, { scheme: 'bearer', expiresIn: '15m' });
// Browser clients: set the signed auth cookie (session id is rotated automatically)
// await this.signIn({ sub: user.id, name: user.name, roles: user.roles }, { scheme: 'cookie', isPersistent: true });
this.returnJson({ token });
}
async logout() { await this.signOut({ scheme: 'cookie' }); this.returnJson({ ok: true }); }
}this.signIn(claims, { scheme, expiresIn | isPersistent }) issues a token for bearer schemes and sets the signed cookie for cookie schemes (rotating the session id when sessions are enabled). master.auth.issueToken(claims, { expiresIn: '15m' }) and verifyToken(token) are available outside controllers. Password hashing is scrypt (N=215, r=8, p=1, 64-byte key, 16-byte salt) with needsRehash for parameter upgrades.
Custom schemes#
API keys, external identity providers, anything else: return claims or null.
master.auth.addScheme('apiKey', {
type: 'custom',
async authenticate(requestObject) {
const key = requestObject.request.headers['x-api-key'];
const partner = key && await lookupPartner(key);
return partner ? { sub: partner.id, name: partner.name, roles: ['partner'] } : null; // claims or null
},
challenge(requestObject) { requestObject.response.writeHead(401, { 'WWW-Authenticate': 'ApiKey' }); requestObject.response.end(); },
});ClaimsPrincipal#
Claims come from the JWT payload, cookie, or custom result: sub → id, name/preferred_username/email → name, role/roles → roles. Methods: hasClaim(type, value?), isInRole(role), findFirst(type), findAll(type), toJSON().
async me() {
const u = this.user; // ClaimsPrincipal — always set once routing runs (anonymous when nothing authenticated)
if (!u.isAuthenticated) return this.unauthorized();
this.ok({
id: u.id, // from 'sub'
name: u.name, // from name / preferred_username / email
roles: u.roles, // from role / roles
admin: u.isInRole('admin'),
verified: u.hasClaim('email_verified', true),
level: u.findFirst('level'),
claims: u.claims, // everything, as sent
});
}