npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 cases

Establishing 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-auth

better-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" means revokeAll plus bumping session_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 back unknown: 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-auth

Tokens, 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.