@bevingh/auth
v0.1.0
Published
Dual-secret access+refresh JWT with pluggable revocation, cookie helpers, thin Bearer verify, and optional Express adapters (not product OTP/guest domains).
Downloads
196
Readme
@bevingh/auth
Phase 3 / PR-13 — extracted. Dual-secret JWT + pluggable revocation + single-secret bearer + service API-key surface. Thin Express adapters (KD13).
Purpose
Dual-secret access+refresh JWT with cookie helpers, thin Bearer verify, and optional Express middleware adapters (not product OTP/guest domains).
| Field | Value |
|---|---|
| surfaceShape | pure_core_plus_express_adapter |
| dependsOnPackages | @bevingh/errors (real implementation) |
| extractionOrderHint | 3 |
| status | extracted (implementation + tests) |
Design decision: pluggable revocation (not a silent winner)
Phase 1 found four different revocation/session architectures:
| Strategy | Repos | Mechanism | |---|---|---| | token_version | roomsplit | JWT claim vs stored per-user integer; bump = revoke all | | blocklist | mirrly (Redis), Academicx (memory), UVT nomination | lookup jti/token as revoked | | persisted RefreshToken | imep-portal-api | refresh validity = live DB row (not just a deny-list) | | none (gap) | UVT ticketing (vs nomination) | no check — security inconsistency |
These are not small variants of one algorithm. The package does not hard-code a single strategy.
RevocationChecker interface
interface TokenContext {
kind: 'access' | 'refresh';
token: string; // raw JWT
payload: AccessTokenClaims | RefreshTokenClaims;
}
interface RevocationChecker {
isActive(ctx: TokenContext): boolean | Promise<boolean>;
}| Factory | Maps to |
|---|---|
| createTokenVersionRevocationChecker(getTokenVersion) | Reference default — roomsplit security baseline |
| createBlocklistRevocationChecker(isRevoked) | mirrly / Academicx / UVT nomination |
| createNoRevocationChecker() | Explicit opt-out only — never silent default |
Persisted RefreshToken (imep): implement isActive yourself — for kind === 'refresh', require a live hashed-token row; for access, use short TTL and/or your own session rule. The interface covers this without forcing imep onto token_version.
Does the interface unify cleanly? Yes as an injection surface. It does not unify the storage models into one algorithm (that would be a false merge). Apps keep their store; they only implement isActive.
Middleware factories require a revocation argument. Omitting checks means passing createNoRevocationChecker() on purpose (addresses UVT ticketing gap).
Claim key normalization: sub
All minted/verified access tokens use JWT standard claim sub for the principal id.
| Repo pattern | On adopt |
|---|---|
| roomsplit, ussd-service, payment-gatway (payload.sub) | already aligned |
| apps using userId (or similar) in JWT payload | map userId → sub at sign time, or accept a one-time claim migration |
Public API (pure core — @bevingh/auth)
| Area | Exports |
|---|---|
| Dual-secret JWT | createDualSecretTokenService |
| Single-secret JWT | signSingleSecretAccessToken, verifySingleSecretAccessToken |
| Cookies | refreshCookieOptions, REFRESH_COOKIE_NAME |
| Revocation | createTokenVersionRevocationChecker, createBlocklistRevocationChecker, createNoRevocationChecker |
| Auth core | authenticateAccessToken, extractBearerToken, assertHasRole, authenticationRequired, forbidden |
| Service API keys | parseApiKeyEnvironment, matchApiKey (separate from user sessions) |
| Arkesel edge | verifyArkeselSignature |
Express — @bevingh/auth/adapters/express
| Export | Role |
|---|---|
| createRequireAuth / createDualSecretRequireAuth / createSingleSecretRequireAuth | Bearer + required revocation |
| createRequireRoles | role gate |
| createArkeselAuth | pg/ussd HMAC ingress |
| createApiKeyAuth | service-to-service (conduit-style); inject loadCandidates + bcrypt compare |
import {
createDualSecretTokenService,
createTokenVersionRevocationChecker,
} from '@bevingh/auth';
import { createDualSecretRequireAuth, createRequireRoles } from '@bevingh/auth/adapters/express';
const tokens = createDualSecretTokenService({
accessSecret: process.env.JWT_ACCESS_SECRET!,
refreshSecret: process.env.JWT_REFRESH_SECRET!,
});
const revocation = createTokenVersionRevocationChecker(async (sub) => {
const user = await db.users.findById(sub);
return user?.token_version ?? 0;
});
app.use(createDualSecretRequireAuth({ tokens, revocation }));
app.get('/admin', createRequireRoles('admin'), handler);What was NOT extracted
| Item | Reason | |---|---| | Didipay refresh | Stubbed / non-functional — do not champion | | Didipay OTP | Optional future surface; not mixed into session core this PR | | Bevin-Photos guest_cap | product domain | | maame Supabase JWKS | project-specific IdP | | Product User models | apps inject loaders / version getters |
Byte-identical pg/ussd finding (documented + ported)
requireAuth + requireRoles + arkeselAuth are shared lineage between payment-gatway and ussd-service. Ported as:
- single-secret sign/verify +
createSingleSecretRequireAuth createRequireRolescreateArkeselAuth/verifyArkeselSignature
This is dedup evidence, not a separate package.
openVariances status
| Variance | Handling |
|---|---|
| dual-secret revocation strategies | Pluggable interface — not forced merge |
| Didipay incomplete refresh | Not extracted |
| claim key shapes | Normalized to sub; mapping documented |
| UVT blacklist optionality | Revocation required on middleware; no-op is explicit |
Tests
npm run test -w @bevingh/auth
npm run build -w @bevingh/authCoverage: dual/single JWT round-trip, expired rejection, token_version + blocklist checkers (two mocks), Express bearer + roles + arkesel.
Champion files (read-only)
- roomsplit
jwt.js,auth.service.js(tokenPair / refresh version check pattern),auth.middleware.js - ussd-service
middleware/auth.js(+ arkesel via sibling) - conduit
apiKeyAuth.js(mechanism only)
mustNotContain (verified)
| Constraint | Status |
|---|---|
| Bevin-Photos guest_cap | OK |
| maame Supabase JWKS | OK |
| Didipay stubbed refresh as champion | OK |
| product user models | OK — only sub / injectable getters |
