@exortek/auth is a framework-agnostic, server-only authentication toolkit
for Node.js 22+, structured as 20 focused packages under one npm scope.
This document describes the design decisions that shape the whole codebase.
Which packages are currently on npm lives in README.md; this
document is about why the shape is the shape.
A modern successor to Passport.js. The problems this repository sets out to solve, in one sentence each:
- Passport's ecosystem is scattered. Hundreds of strategies, mismatched APIs, unmaintained corners. We ship one scope, one API family.
- Modern JOSE is table stakes.
joseis excellent but runtime-agnostic and JOSE-only. We want a JOSE stack that plugs into the same server-only toolkit as our session, password, and CSRF layers. - Cryptographic footguns are library defaults.
alg: 'none'acceptance, optional algorithm allowlists, symmetric-secret alg confusion, weak-hash-by-default. We refuse them by construction, not by config. - The Node standard library is enough.
node:cryptocovers everything we need up to the post-quantum transition. Runtime dependencies are the exception, not the rule.
These are the invariants — expect PRs that violate them to be rejected.
Every package depends on node:crypto and its declared peer dependencies —
nothing else, with no external-dependency exceptions.
Leaf packages do not import from another @exortek/* package at runtime.
Utility duplication (a base64url helper in every JOSE package, an
ErrorCode enum per package) is accepted deliberately, and the private
@exortek/shared workspace is bundled into each package's dist rather
than published — so a user who installs a leaf package pulls no workspace
peers.
The sanctioned exceptions are packages that compose the published stack and cannot reasonably re-bundle it:
@exortek/sessionimportsseal/unseal/CryptoErrorfrom@exortek/crypto.@exortek/jwksimports@exortek/jwk.@exortek/oauth2composes the JOSE stack — it declares@exortek/jwt,@exortek/jwks,@exortek/jwk,@exortek/jws,@exortek/jwe, and@exortek/pasetoas runtime dependencies. Bundling them would duplicate class identities (a bundledJwtErrorwould not beinstanceofa consumer's own@exortek/jwt) and bloat every entry, so they are externalised and declared instead.
Each such package declares those @exortek/* packages in its package.json,
so installing it pulls them with it — call this out to anyone marketing "zero
dependencies" downstream. Everything a package uses from the private
@exortek/shared is still bundled, so no unpublished package is ever a
runtime import.
Every other shipped package (crypto, otp, password, security, jwk,
jws, jwt, jwe, paseto at time of writing) is fully standalone at
runtime.
The dependency graph below tells you the semantic order, not the runtime import graph. It is preserved so package authors know which package is allowed to know about which concept.
Every package targets Node.js. No browser code, no crypto.subtle
polyfills, no window / document references, no /client subpaths.
For protocols with an inherent browser side (WebAuthn, OAuth2
SPA / PKCE, OPAQUE), we verify server-side and point users at a maintained
companion library — see the "Server-only" section of AGENTS.md.
Defaults choose safety over ergonomics. alg is always required — there is
no "guess from the key" or "default to HS256". Every failure surfaces with
a granular ErrorCode you can branch on. Nothing runs behind an environment
variable.
The codebase is .js, not .ts. tsc --emitDeclarationOnly generates
.d.ts from JSDoc during build. Consumers get full IDE hints without a
TypeScript toolchain in dependencies.
Where a specification publishes reference vectors (RFC 4226 §5.4, RFC 6238 §B, RFC 7638 §3.1, RFC 7515 Appendix A), we hard-code them in tests. They are the canary for spec-compliance regressions.
Numbers reflect dependency order — a lower number never imports from a
higher one, so packages can be adopted incrementally. The current shipping
status lives in README.md.
Status legend: ✅ shipped to npm · 🛠 on disk, pre-release · ⏳ planned.
| # | Package | Status | Responsibility |
|---|---|---|---|
| 01 | @exortek/crypto |
✅ | Primitives — hash, HMAC, KDFs, cipher, sign, seal, encode, CSPRNG |
| 02 | @exortek/password |
✅ | Argon2id / scrypt / bcrypt / PBKDF2 + strength / pepper / history / HIBP |
| 03 | @exortek/otp |
✅ | RFC 4226 HOTP + RFC 6238 TOTP, backup codes, otpauth:// provisioning URI |
| 04 | @exortek/challenge |
✅ | HMAC-signed multi-step flow tokens (userId · method · step) with opt-in single-use |
| 05 | @exortek/jwk |
✅ | JWK — generate, import, export, thumbprint, per RFC 7517 / 7638 / 8037 / 9278 |
| 06 | @exortek/jws |
✅ | JWS — sign / verify / detached / b64:false / JSON serialisation per RFC 7515 / 7797 |
| 07 | @exortek/jwt |
✅ | JWT — sign / verify with algorithm allowlists + claims validation per RFC 7519 |
| 08 | @exortek/jwe |
✅ | JWE — RSA-OAEP, ECDH-ES, A256KW, dir per RFC 7516 |
| 09 | @exortek/jwks |
✅ | JWKS URI fetching, caching, kid rotation per RFC 7517 §5 |
| 10 | @exortek/session |
✅ | Sealed-cookie sessions, rotation, revocation, sudo mode, Redis pub/sub |
| 11 | @exortek/security |
✅ | CSRF, rate-limit, helmet-style headers, CORS, safe-redirect, defensive HTTP helpers |
| 12 | @exortek/ua |
✅ | User-Agent parsing, device/browser/bot detection, Client Hints, fingerprinting |
| 13 | @exortek/apikey |
✅ | Prefixed API keys (sk_live_id_secret), HMAC-SHA256 storage, scopes, middleware |
| 14 | @exortek/magic-link |
✅ | Passwordless email-link auth — signed short-lived tokens with single-use consume |
| 15 | @exortek/passkey |
✅ | WebAuthn Level 3 / FIDO2 server — all seven attestation formats + MDS3 + AAGUID |
| 16 | @exortek/opaque |
✅ | Opaque reference tokens, RFC 7662 introspection + RFC 7009 revocation |
| 17 | @exortek/paseto |
✅ | PASETO v4 — v4.local (XChaCha20 + keyed BLAKE2b) + v4.public (Ed25519), no alg header |
| 18 | @exortek/oauth2 |
✅ | OAuth 2.1 — RP flow + provider presets + full authorization server (DPoP · PAR · JAR/JARM · device · token-exchange · dynamic registration · opt-in OIDC id_token · FAPI) |
| 19 | @exortek/oidc |
⏳ | OpenID Connect on top of @exortek/oauth2 |
| 20 | @exortek/auth |
⏳ | Umbrella — re-exports every package above |
Not versioned in this table: @exortek/shared — internal consolidation
workspace (see PLAN.md), not published on its own.
crypto
│
├─── jwk ─── jws ─── jwt ─── jwe
│ │
│ └── jwks
│
├─── opaque, paseto, password, otp
│
├─── magic-link, passkey, session, ua
│
├─── security (csrf, rate-limit, headers, cors, redirect)
│
└─── oauth2 ─── oidc
otp → challenge
apikey → security/rate-limit
The umbrella @exortek/auth re-exports every package. Individual packages
are also independently consumable.
Every package follows the same shape:
packages/<name>/
├── src/
│ ├── index.js # public entrypoint — named exports + namespace object
│ ├── <feature>.js # public modules (one per subpath export)
│ └── internal/*.js # helpers not part of the public API
├── tests/*.test.js # tests directory (NOT colocated in src/), run via `node --test`
├── rollup.config.js # thin call into rollup.config.base.js
├── tsconfig.json # extends ../../tsconfig.base.json
├── package.json # per-subpath `exports`, `files`, `scripts`
├── LICENSE # MIT — copied from a sibling
├── README.md # public docs
└── CHANGELOG.md # generated by Changesets on release
- Dual output.
dist/<name>.mjs(ESM) +dist/<name>.cjs(CJS) per subpath. - JSDoc →
.d.ts.tsc --emitDeclarationOnlyemits declarations, then arollup-plugin-dtspass flattens each entry into one self-contained file — inlining the private@exortek/sharedtypes (see below) so the shipped declarations never reference the unpublished workspace package. - Tests live in
tests/, run through Node's nativenode --testrunner. No Jest / Mocha / Vitest.
Packages that ship subpath exports use one rollup input/output pair per
subpath. Existing examples: @exortek/jwk (./generate, ./import,
./export, ./thumbprint, ./validate), @exortek/jws (./sign,
./verify, ./decode, ./json).
@exortek/shared is a private, never-published workspace package. Every
@exortek/* package imports from it (@exortek/shared/predicates,
@exortek/shared/asserts, …) but declares it nowhere: rollup inlines its
source into each package's dist at build time (Design principle 2), so a
standalone install pulls no shared peer. Rules to respect:
- Never "fix" the missing
dependenciesentry — it would break the standalone-install guarantee. - Never publish
shared. - It emits no
.d.tsof its own. So consumers don't inherit a danglingimport … from '@exortek/shared/*'in their types, the declaration-bundling pass (see "Per-package layout") inlinesshared's JSDoc-derived types into each package's own.d.ts.
Stateful packages (jwt, paseto, apikey, magic-link, opaque, session, challenge,
security, oauth2) each need a memory + Redis + custom backing store. Instead of
re-implementing that per package, shared ships one factory per store
family; a package binds it once and layers its domain methods on top:
record-store— indexed record CRUD + secondary owner-index (apikey, magic-link, oauth2 refresh family).registry-store— blacklist / refresh-token registry with atomicmarkUsed(jwt, paseto).incr-store— atomic TTL counter for replay guards / rate limits (challenge, magic-link, security).custom-store—createCustomStoreValidator/assertStoreShape: validate a caller-supplied store and promote its sync methods to the async interface.redis-helpers— the single ioredis / node-redis / Upstash dialect layer (detectDialect,evalScript,setWithTTL); every Redis store dispatches through it, so a driver quirk is fixed in one place.
Adding a store means composing these, not re-writing memory/Redis/dialect plumbing. Genuinely different shapes (session's anonymous-first eviction, security's bucket compare-and-set) stay in their package — shared owns the mechanics, the package owns its domain.
The JOSE packages (jwk, jws, jwt, jwe, jwks) diverge from jose
the library in a handful of deliberate ways. These are the reasons the
stack exists — preserve them when extending.
- Algorithm allowlist is mandatory on verify. Omitting it raises
MISSING_ALG_ALLOWLIST. No default, no fallback. alg: 'none'is refused everywhere. No flag, no environment variable, no configuration. RaisesALGORITHM_NONE_FORBIDDEN. Defence in depth: the algorithm table has nononeentry and the sign / verify surfaces short-circuit before any lookup.critis strict by default. Unknown critical headers raiseCRIT_UNSUPPORTED. Callers can opt in named extensions viaknownCriticalHeaders.- Key input is polymorphic. JWK object,
KeyObject,Buffer(HMAC only), JWK array (kid dispatch), andasync (header) => keyresolver functions are all first-class. - Granular
ErrorCodeper package. Branch onerr.code, never onerr.message.
ML-DSA (FIPS 204, signatures) and ML-KEM (FIPS 203, key encapsulation) are on the roadmap for the JOSE stack. Shipping them today would mean bundling a JS lattice-crypto implementation — a red line.
Timeline gate:
- OpenSSL 3.5 (April 2025) added ML-DSA / ML-KEM.
- Node 22–24 ships OpenSSL 3.0–3.4 — no
generateKeyPair('ml-dsa-65')yet. - Node 25 / 26 (2026–2027) will expose them once the OpenSSL bump lands.
draft-ietf-jose-pqc— JWK / JWS registrations with provisionalkty: "AKP"— is still a draft.
When both boxes tick (Node native primitives + stable JOSE registrations),
we add ML-DSA-{44,65,87} and ML-KEM-{512,768,1024} to the same
surface. Until then, users needing PQ today can wire in
@noble/post-quantum.
README.md— the public entrypoint. Ground truth for which packages are on npm.packages/<name>/README.md— per-package public docs (badges, why, quick start, error catalogue, highlights).web/— Nextra docs site published toauth.memet.dev. Per-module deep dives per shipped package.AGENTS.md— agent-agnostic contributor guide (workflows, conventions, non-goals).SECURITY.md— vulnerability reporting + supported-versions matrix.CONTRIBUTING.md— human contributor onboarding.docs/compliance.md— mapping onto NIST SP 800-63B, OWASP ASVS 4.0.3, PCI-DSS 4.0.
Where each protocol is anchored:
| Package | RFC / spec references |
|---|---|
crypto |
NIST SP 800-108 (KDF), NIST SP 800-38D (GCM), RFC 5869 (HKDF), RFC 8018 (PBKDF2), RFC 8785 (JCS) |
password |
Argon2 spec (2015), NIST SP 800-63B §5.1.1, OWASP ASVS V2 |
otp |
RFC 4226 (HOTP), RFC 6238 (TOTP), Google Authenticator otpauth:// URI spec |
jwk |
RFC 7517 (JWK), RFC 7518 §6 (JWK parameters), RFC 7638 (thumbprint), RFC 8037 (OKP), RFC 8812 (secp256k1), RFC 9278 (thumbprint URI) |
jws |
RFC 7515 (JWS), RFC 7518 §3 (JWA), RFC 7797 (unencoded payload), RFC 8037, RFC 8812, RFC 8725 (BCP) |
jwt |
RFC 6749 §10.4 (refresh reuse), RFC 7519 (JWT), RFC 8725 (BCP), RFC 9068 (JWT profile for OAuth2) |
jwe |
RFC 7516 (JWE), RFC 7518 §4–§5 (key management + content encryption), RFC 8725 (BCP) |
jwks |
RFC 7517 §5 (JWK Set), OpenID Connect Discovery |
opaque |
RFC 7662 (Token Introspection), RFC 7009 (Token Revocation) |
paseto |
PASETO v4 (paseto.io spec), RFC 6749 §10.4 (refresh reuse), RFC 7693 (BLAKE2b) |
session |
OWASP ASVS 4.0.3 V3, RFC 6265 (Cookies) |
security |
OWASP ASVS 4.0.3 V13 / V14, RFC 6749 §10 (OAuth2 threats), RFC 7231 §5 (HTTP) |
oauth2 |
OAuth 2.1 / RFC 9700 (BCP), RFC 6749, RFC 7636 (PKCE), RFC 9207 (iss), RFC 8414 (metadata), RFC 9449 (DPoP), RFC 9126 (PAR), RFC 8707 (resource), RFC 9396 (RAR), RFC 9101 (JAR/JARM), RFC 7523 / 8705 (client auth), RFC 8693 (exchange), RFC 8628 (device), RFC 7591 (dynamic registration), RFC 9068 (JWT profile), OpenID Connect Core (id_token, opt-in OP mode), FAPI 2.0 |
oidc |
(planned) OpenID Connect Core 1.0, OpenID Connect Discovery |
passkey |
W3C WebAuthn Level 3, FIDO2 CTAP2 |
For deeper per-package interface tables (JSDoc typedefs, worked API
examples, migration notes) look at a shipping package alongside its
README and per-module docs on auth.memet.dev.