@aria-framework/auth
v0.12.0
Published
Aria App Framework — auth module. An injectable RBAC permission resolver (createRbac) and an Express staff-session guard + permission gates (createAuth). Catalog, DB loader, session hooks, and views all injected; no app-specific content baked in.
Readme
@aria-framework/auth
Aria App Framework — the shared auth/authz layer. Two factories, no module-level state and no app-specific content baked in:
createRbac— an in-memory role→permission cache for O(1) checks. You supply how to load your roles and your full permission set; the resolver does the rest.admin(configurable) always holds every permission.createAuth— an Express staff-session guard (requireAuth) plus the permission gates (requirePermission/requireAnyPermission/requireRole), built from an rbac instance and a few injected hooks.
A second principal (a customer-portal contact, an API JWT user) is not here — that's app-specific; keep its guard in the app. This package is the staff/ admin session path both apps share.
createRbac
const { createRbac } = require('@aria-framework/auth');
const { ALL_PERMISSIONS } = require('./permissions'); // your catalog
const rbac = createRbac({
loadRolePerms: () => db.invoke('Role', 'permissionList'), // -> [{name, permissions:[]}]
allPermissions: ALL_PERMISSIONS, // admin superset + isValidPermission oracle
adminRole: 'admin', // default 'admin'
logger
});
await rbac.load(); // at boot (and refresh() after role edits)
rbac.can('agent', 'tickets.edit'); // O(1), case-insensitive role
rbac.permsFor('agent'); // string[]
rbac.isValidPermission('tickets.x'); // catalog membershipThe permission catalog (category structure, role defaults, any implied-view expansion) stays in the app — it's app-specific content. The engine only needs the flat set of valid permissions and a role loader.
createAuth
const { createAuth } = require('@aria-framework/auth');
const { requireAuth, requirePermission, requireAnyPermission, requireRole } = createAuth({
rbac,
getSessionTimeoutMs: () => dbClient.getSessionTimeoutMs(), // sync, cached
getSessionVersion: (userId) => dbClient.invoke('User', 'getSessionVersion', userId),
loginPath: '/login', // default '/login'
forbidden: (req, res) => res.status(403).render('errors/403', { title: 'Access Denied' })
});
app.use('/tickets', requireAuth, ticketsRouter);
router.post('/x', requirePermission('tickets.edit'), handler);requireAuth enforces: session presence (anon → loginPath, stashing a
returnTo for real page navigations), an idle timeout (getSessionTimeoutMs),
and a session-version check — when req.session.sessionVersion is set, it's
compared to getSessionVersion(userId) and the session is destroyed on mismatch
(a role/password change or deactivation drops existing sessions next request);
it fails closed if the version read throws. On success it sets
res.locals.currentUser and res.locals.currentPath. The lastActivity write
is throttled (default 60s) to avoid whole-blob session-save races.
The gates read req.session.user.role and consult rbac.can; anon →
loginPath, insufficient → your forbidden(req,res) (default: plain-text 403).
Session shape expected
req.session.user (with .id and .role), optional .sessionVersion,
.lastActivity, .returnTo; req.session.destroy(cb). Standard express-session.
createEntraAuth({ getStore, logger }) (0.2.0)
The server-side Microsoft Entra ID OIDC authorization-code flow. Returns
{ getConfig, isSignInConfigured, reset, generatePkce, getAuthCodeUrl, redeemCode }.
const { createEntraAuth } = require('@aria-framework/auth');
const entraAuth = createEntraAuth({ getStore: () => encryption.getSecureStore(), logger });Reads the entra SecureStore row (enabled, client_id, tenant_id,
client_secret). The browser is only ever redirected — Microsoft tokens never touch
page JavaScript — and the code exchange happens over the authenticated back-channel,
so no JWKS or signature validation is needed here.
redeemCode() returns { ok, username, displayName, oid, tid, claims } and fails
closed when the token carries neither oid nor sub: falling through to
username-only resolution is the mutable-identifier bug both consumers fixed
independently. oid and tid are coerced to strings — they end up as DB bind
parameters.
What it deliberately does NOT do: resolve a local user, gate on auth_source,
bind an identity, touch a session, audit, or flash. That is the app's callback route.
Values in, values out; it never sees req/res.
Two behaviours that look removable and are not: the client is memoised on
clientId|tenantId|clientSecret, so a rotated secret rebuilds rather than serving
a client holding the old one until restart; and isSignInConfigured() caches for 30s
because it decrypts, and both the login page and a mobile probe call it per request.
expandImpliedViews(perms, categories) (0.2.0)
For every category in which a role holds at least one permission, also grant that
category's <id>.view — when the catalog declares one. Lets a module mount gate
require <category>.view strictly without locking a manage-only custom role out
of its own module.
Apply at write time (role create/update), so what is stored is what is enforced. Pure, and defensive by design: bad input returns the input rather than throwing, because it runs inside a role save.
Promoted from Acc101, which had the general rule; the other consumer had solved the same problem once by hand with a migration.
Changelog
0.2.0 — adds
createEntraAuth(the server-side Entra OIDC sign-in flow, promoted on the two-consumer rule: both apps held copies whose exported surface was character-for-character identical) andexpandImpliedViews(promoted from Acc101, which had the general rule where the other app had patched one pair by migration). Additive —createRbacandcreateAuthare unchanged. MINOR because nothing here is urgent and a consumer must change code to benefit, so a manifest bump costs nothing it was not already paying.0.1.1 — the expired-session redirect now appends
expired=1with the correct separator, so aloginPaththat already carries a query string isn't mangled into a double-?URL.0.1.0 — first release. Extracted from Support101/Acc101
lib/rbac.js+middleware/auth.js(near-identical forks). The mechanism is shared; each app injects its permission catalog, role loader, session hooks, login path, and 403 view. App-specific second principals (portal contact) stay in the app.
