MasterController

Authentication & Authorization

Schemes authenticate, policies authorize, [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 CoreMasterController
AddAuthentication().AddJwtBearer(...)master.auth.addJwtBearer(name, { secret | publicKey, issuer, audience })
AddAuthentication().AddCookie(...)master.auth.addCookie(name, { secret, loginPath, mode: 'web' | 'api' })
custom AuthenticationHandlermaster.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.Userthis.user / requestObject.user
SignInAsync / SignOutAsyncawait 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
// 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 with publicKey (or a keyResolver) and algorithm. Verification enforces the algorithm allow-list (no alg: none), exp/nbf with 60 s tolerance, and iss/aud when configured.
  • Cookie — HMAC-SHA256 signed, verified in constant time; secret may be an array for rotation. mode: 'api' (default) answers 401/403 JSON, mode: 'web' redirects to loginPath?returnUrl=… / accessDeniedPath. HttpOnly, SameSite and Secure ('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
// 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
postsController.js
// 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#

sessionController.js
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.

javascript
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: subid, name/preferred_username/emailname, role/rolesroles. Methods: hasClaim(type, value?), isInRole(role), findFirst(type), findAll(type), toJSON().

javascript
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
  });
}
Secrets
HMAC secrets must be at least 32 characters. Keep them out of the repo — read them from configuration (user secrets in development, environment variables in production).