@aria-framework/api-auth
v0.2.0
Published
Aria App Framework — api-auth module. Bearer-token authentication for mobile/API clients: a short-lived access JWT plus an OPAQUE refresh token stored only as a hash, single-use rotation in one transaction, reuse detection that revokes the family, an opti
Readme
@aria-framework/api-auth
Bearer-token authentication for mobile and API clients. A short-lived access JWT, an opaque refresh token stored only as a hash, single-use rotation with reuse detection, an optional absolute session lifetime, and a guard that re-checks the user on every request.
It does not know how you signed in. That is the whole design.
const user = await authenticateHowever(req); // password, Entra, Google, SAML — yours
const pair = await auth.issue(user); // identical in every one of those casesEstablishing who someone is and keeping them signed in are different problems. Adding a login provider should touch a login route and nothing in this package.
Install
npm i @aria-framework/api-authbetter-sqlite3 is an optional peer — the package never requires it. You supply getDb().
Wiring
Two halves, because the apps this came from run SQLite in a worker thread.
Worker side — the ledger, where the SQL runs:
const { createLedger } = require('@aria-framework/api-auth');
module.exports = createLedger({
getDb: () => db,
maxSessionDays: 0 // 0 = unlimited (default); 30 = re-authenticate monthly
});Expose it to the bridge under a model name (default RefreshToken).
Main side — the flow and the guard:
const { createApiAuth } = require('@aria-framework/api-auth');
const auth = createApiAuth({
secret: () => credentials.get('jwt_secret'), // a FUNCTION: read late, not at load
invoke, // (model, method, ...args) => Promise
loadUser: (id) => Users.findActive(id), // ACTIVE users only — see below
onReuse: ({ userId, familyId }) => audit(...) // optional
});
app.use('/api', auth.requireAuth());The table
The app owns the migration; the package supplies the columns.
const { SCHEMA } = require('@aria-framework/api-auth');
db.exec(`CREATE TABLE refresh_tokens (${SCHEMA});`);
db.exec('CREATE INDEX idx_refresh_family ON refresh_tokens(family_id);');
db.exec('CREATE INDEX idx_refresh_user ON refresh_tokens(user_id);');The routes you write
// POST /api/auth/login — yours entirely: rate limit, verify, then:
const { accessToken, refreshToken } = await auth.issue(user);
// POST /api/auth/refresh
const r = await auth.refresh(req.body.refresh_token);
if (!r.ok) return res.status(401).json({ error: r.reason });
res.json({ access_token: r.accessToken, refresh_token: r.refreshToken });
// POST /api/auth/logout
await auth.revoke(req.body.refresh_token);refresh() returns a reason rather than throwing, because the cases mean different things:
| reason | what happened | what to do |
|---|---|---|
| reuse | a spent token was replayed. The family is already revoked | log in again — and this one is worth telling the person about |
| unknown | no such token: forged, or spent long enough ago to be purged | log in again |
| expired | idle past refreshDays | log in again |
| session_expired | past maxSessionDays, however active | log in again |
| inactive | the account is disabled or gone | log in again |
| missing | no token was sent | — |
What it guarantees, and why each one is there
The refresh token is opaque, and the ledger stores a SHA-256 of it. Its contents are never trusted — only the lookup decides anything — so a JWT would buy nothing and cost a signature-verification path, an algorithm-confusion surface, and a payload disclosing the user id if it leaked. And because an opaque token is the secret, storing it verbatim would mean one stray database read — a leaked backup, a support export, an injection anywhere — handing over every live session. Plain SHA-256, no salt and no bcrypt: the input is 256 bits of entropy, not a guessable password.
Rotation is single-use and atomic. Redeeming spends the presented token and writes its replacement in one transaction. Split them and a failure between the two consumes the client's token without recording its replacement — after which an honest retry is indistinguishable from an attack.
A spent token is kept, not deleted. Deleting works: the replay is refused. But refusing is then all you can do — with no row there is nothing to say which session it belonged to. Keeping it turns a refusal into a detection.
Reuse revokes the family, not the user. A family is one lineage: a login, and everything rotated from it — in practice, one device. The person's other devices did nothing wrong and stay signed in.
loadUser must return only ACTIVE users, and it is called on EVERY request. That gives up
the JWT's main advantage — skipping a database read — on purpose. A disabled account or a
bumped session_version must fail on the very next call, not whenever a two-hour token happens
to expire. If loadUser returns a disabled user, nothing else in the package will catch it.
session_version is a shared kill switch. It rides in the access token and is compared
against the user on every request, so the same column that signs someone out of the web signs
them out of the API.
secret is a function. The apps keep their signing key in an encrypted store that is not
open when modules load. A value captured at construction would be undefined.
The JWT algorithm is pinned to HS256, and is not configurable. secret() returns a
symmetric secret by construction, so nothing else can ever be correct. An unpinned verifier
lets the token's own header choose how it is checked — which is how an HS512 token signed with
the same secret, or an RS256 token verified with its public key as an HMAC key, gets accepted.
What stays yours
Login routes and their rate limits, password verification, every provider exchange, what a public user looks like on the wire, permission names, and the mount path.
Known limits
- Revoking a refresh token does not kill an access token already in flight. It expires on
its own within
accessTtl(default 2h). "Sign out now" meansrevokeAllplus bumpingsession_version. - The reuse-detection window is
refreshDays. Spent rows are retained that long and then purged — that retention is the window. A replay after it comes backunknown: safe, but unattributed. purgeExpired()is called opportunistically on issue, never awaited. A ledger that grows costs disk, not correctness.
Test
npm test -w @aria-framework/api-authTokens, the ledger against a real SQLite database, and the whole flow through an async worker bridge — including a rotation forced to fail halfway, a replay, a hook that never settles, and a database outage.
Service accounts — one app calling another's API
A second credential type, for a machine rather than a person. Same primitive as the refresh token — an opaque secret stored as a SHA-256 in a revocable ledger — minus rotation and expiry, plus a scope list.
const { createServiceAuth, createKeyLedger, KEY_SCHEMA } = require('@aria-framework/api-auth');
// worker side
module.exports = createKeyLedger({ getDb: () => db });
// main side
const service = createServiceAuth({ invoke, model: 'ApiKey', logger });
// issuing — THE KEY IS RETURNED ONCE AND NEVER AGAIN
const { key, id, hint } = await service.issue({
name: 'SOC101 monitoring',
scopes: ['tickets:read', 'tickets:write'],
allowedIps: ['198.51.100.4'], // optional; omit for any address
createdBy: req.session.user.id
});
// guarding
router.use('/integration', service.requireScopes('tickets:read'));
router.post('/integration/tickets', service.requireScopes('tickets:write'), handler);The table is the app's; the columns are KEY_SCHEMA.
Why not just give the app a user account
It works, until it does not, and the first failure is the surprising one:
| | |
|---|---|
| the refresh token rotates | correct for one phone, wrong for N workers. Two processes sharing one token means the second replays a spent one, reuse detection revokes the family, and the app locks itself out — worse the harder it retries |
| login throttles | a per-username failed-login counter turns a retry storm into a lockout |
| attribution | created_by_user_id pointing at a fake person makes the audit trail lie |
| roles are human-shaped | and drift wider; a machine wants an explicit list |
What it guarantees
A service is not a user. requireScopes() sets req.service and deliberately does NOT shim
req.session.user the way requireAuth() does. A machine has to be visible as a machine, or
the audit trail claims somebody logged a ticket at 03:14 when a link flapped.
Scopes are exact, and ALL of them. No wildcards — tickets:* is convenient and is exactly
how a key ends up doing more than anyone intended. A route asking for two scopes needs both.
Every auth failure looks the same on the wire. Unknown, revoked, expired and wrong-address are four log lines and one 401: a distinct "revoked" reply would confirm to an attacker that the key was once real. An insufficient scope is a 403 and does name what was needed — a capability is not a secret, and a caller that retries authentication would otherwise loop forever.
Revocation is a soft delete. revoked_at, not a DELETE, because "which key, and when was it
withdrawn?" is the question asked after an incident, and rows created by that key still reference
its id.
last_used_at is throttled to one write a minute. Unthrottled it turns every authenticated
read into a database write, serialising against the single writer.
The allow-list has a trap worth stating
req.ip is the proxy's address unless the app sets trust proxy correctly. An allow-list
checking the wrong value either blocks everything or allows everything, and neither announces
itself. Test it through a proxied request, not a direct one.
Matching is exact addresses only — no CIDR yet, because a range needs a parser per address family. The absence is a missing feature, not a silent pass.
What stays the app's
Rate limiting (this gives you req.service.id to key a limiter on — a monitoring system in a
loop and a person mistyping a password need different budgets), which customers a key may act
for, the scope vocabulary itself, the admin screen, and the mount path.
