@factory/auth
Authentication primitives for AI Software Factory: HS256 JWT access tokens, refresh-token rotation with family reuse detection, password hashing, and the server-side session registry.
Source-first:
main/typespoint atsrc/index.ts— no build step yet (build is Phase 0 completion).
Design
- node:crypto only — no
jsonwebtoken, no native bcrypt dependency. The JWT wire format is standard compact JWT, so the NestJS guard and the realtime WS handshake verify the same tokens with the same function. - Storage-agnostic —
RefreshTokenStoreandSessionStoreare injected interfaces; the API implements them over prisma (RefreshToken,Session). Every decision in this package is unit-testable in-memory. - Secrets only from env —
signAccessToken/verifyAccessTokentake the secret as a parameter (the API passesenv.JWT_SECRET); nothing here readsprocess.envdirectly.
Public API
| Module | Exports | Purpose |
|---|---|---|
@factory/auth/tokens |
signAccessToken, verifyAccessToken, parseLifetime, generateJti, generateRefreshToken, hashToken, issueRefreshToken, rotateRefreshToken, RefreshTokenStore |
access JWTs + refresh rotation |
@factory/auth/passwords |
passwordPolicy, hashPassword, verifyPassword, PasswordHasher, storedHashAlgorithm |
policy + hashing |
@factory/auth/sessions |
SessionManager, Session, SessionStore |
session registry |
Access tokens
const jwt = signAccessToken({ sub: user.id, workspaceId: wsp.id, roles: ["owner"] }, env.JWT_SECRET); // 15m
const claims = verifyAccessToken(jwt, env.JWT_SECRET); // throws ApiError("UNAUTHORIZED") on expired/tampered/alg≠HS256
alg: "none" and any non-HS256 algorithm are rejected (no algorithm
confusion); signatures are checked with timingSafeEqual; exp is enforced
(30s clock skew allowed on iat).
Refresh rotation (the theft defense)
const issued = await issueRefreshToken(store, { userId: user.id }); // first login
// ... later, on /auth/refresh:
const next = await rotateRefreshToken(store, presentedToken); // same family, new token
- Tokens are 64 random bytes (hex); only the SHA-256 hash is persisted
(prisma
RefreshToken.tokenHash). - Rotation marks the old row
replacedBy → newHash(mirrors prismaRefreshToken.replacedBy) and issues a new row in the same family. - Presenting an already-rotated (or revoked) token again → whole family
revoked +
ApiError("UNAUTHORIZED", "refresh token reuse detected")— an attacker replaying a stolen token kills the legitimate user's chain, and the user must re-authenticate. - Expiry: 30 days (
TOKEN_LIFETIMES.REFRESH_TOKEN_MS).
Passwords
passwordPolicy mirrors @factory/shared passwordSchema exactly (min 10,
max 72, ≥1 letter, ≥1 number). Hashing goes through the PasswordHasher
interface: the scaffold default is PBKDF2-SHA512 (cost 12, 16-byte salt,
$factory$pbkdf2-sha512$… format) because native bcrypt cannot run in a
source-first, install-free scaffold; the API registers the real bcrypt
implementations at bootstrap (bcrypt cost 12 per .env.example) and legacy
$2b$ hashes verify through the registered verifier. storedHashAlgorithm()
tells the two formats apart for migration tooling.
Sessions
SessionManager over an injected SessionStore: create (30d expiry,
sess_ ids), touch (lastActiveAt on successful rotation), revoke,
revokeAllForUser (password change / "log out everywhere"), pruneExpired.
Session rows carry the refresh-token family id — revoking a family revokes
the session and vice versa at the API layer.
Usage
// apps/api/modules/auth/auth.service.ts (sketch)
const tokens = await this.tokenService.issueAfterLogin(user, wsp); // signAccessToken + issueRefreshToken
const session = await this.sessions.create({ userId: user.id, refreshTokenHash: hashToken(tokens.refresh), familyId: tokens.familyId, userAgent, ip });
this.audit.log("user.login", { userId: user.id }); // audit event (AGENTS.md)
Tests
Vitest (node env): tokens.test.ts (JWT roundtrip/tamper/expiry/alg-none,
full rotation + reuse-detection flow with an in-memory store),
passwords.test.ts (policy, roundtrip, unique salts, malformed, algorithm
detection), sessions.test.ts (lifecycle, revoke-all, pruning).
ROADMAP phases that build on this
- Phase 1 (Authentication): this package is the foundation — login, register, refresh, sessions, audit events.
- Phase 7 (Collaboration): WS handshake verifies the same access JWT.
- Phase 13 (API): API keys use the same hash-at-rest pattern.
Recorded decisions & conflicts
- PBKDF2 scaffold vs bcrypt-12 target: the runtime target is bcrypt
(
.env.example, seed hashes); the scaffold ships PBKDF2 behindPasswordHasherso no native module is required to build/typecheck/test this source-first package. Swapping is one interface implementation. - Family reuse detection follows the OWASP refresh-token guidance: a rotated-out token is not silently accepted and not merely ignored — reuse revokes the family (fail-closed, auditable).
- Access tokens carry
workspaceId+rolesclaims so per-request authorization (see@factory/permissions) can start from the JWT and the DB grant check still happens server-side (AGENTS.md: never trust the frontend; the JWT is not an authorization cache).