@quilla-be-kit/security
v0.2.11
Published
Security primitives: Token/TokenService, PasswordHasher, SessionStore interfaces, and auth middlewares (Bearer-token verification + authenticated-session enrichment) that plug into @quilla-be-kit/http's Router.
Maintainers
Readme
@quilla-be-kit/security
Interface-only security primitives + the two auth middlewares that plug into @quilla-be-kit/http's Router:
Token(extendsAuthenticatedTokenfrom http) — verified-credential contract withuserId,scopeId,securityStamp,issuedAt,expiresAt,isExpired(now?: Date). The optionalnowparameter defaults tonew Date()— pass an explicit date in tests for deterministic expiry checks.TokenClaims— canonical short-key wire-format type for the JWT payload (u,si,st,s?for scopes). Implementers map between the readableSignTokenPayload/Tokenshapes and these compact claims at the encode/decode boundary. Tokens travel in every request header — short keys exist for payload size, not security.TokenService—sign(payload, { expiresIn })+verify(raw)interface. Consumer provides the implementation (JWT viajose/djwt/jsonwebtoken, PASETO, opaque reference tokens — your choice).SessionStore— keyed session record storage. Consumer picks the backend (Redis, Valkey, DynamoDB, Postgres).SessionData— record shape:{ securityStamp, displayName, userType }.PasswordHasher—hash+compareinterface. Consumer provides the implementation (argon2 / bcrypt / scrypt).bearerTokenMiddleware({ tokenService })— readsAuthorization: Bearer ..., callstokenService.verify, populatesHttpAttributes.VERIFIED_TOKEN. ThrowsUnauthorizedErroron any failure.authenticatedSessionMiddleware({ sessionStore, executionContextProvider })— loads session data, comparessecurityStamp, enrichesExecutionContextwithactorType: 'user'and a populatedsession: { scopeId, userId }(theAuthSessionfrom@quilla-be-kit/execution-context). ThrowsUnauthorizedErroron stamp mismatch or session miss.
Zero runtime dependencies on external libraries — JWT lib, cache driver, and hashing library are all consumer-owned. Node 22+.
Install
pnpm add @quilla-be-kit/security @quilla-be-kit/http @quilla-be-kit/execution-context @quilla-be-kit/errorsWhy interface-only?
JWT algorithm choice, key rotation strategy, cache backend, and password-hashing cost parameters are security decisions that belong in the consumer's composition root, not in a toolkit. Shipping a built-in adapter for any of these would either:
- Freeze a choice (one hashing algorithm, one JWT library) that teams rightfully contest, or
- Pull in optional peer deps (
bcryptbringsnode-gyp,jose/jsonwebtokenbring opinions about crypto primitives) for what amounts to ~4–10 lines of glue.
Same discipline as RequestValidator in @quilla-be-kit/http: ship the interface, let consumers write the 5-line adapter.
Quick start
import { AsyncExecutionContextProvider } from '@quilla-be-kit/execution-context';
import { Router } from '@quilla-be-kit/http';
import { HonoServer } from '@quilla-be-kit/http/adapter/hono';
import {
authenticatedSessionMiddleware,
bearerTokenMiddleware,
type TokenService,
type SessionStore,
type Token,
type TokenClaims,
type SignTokenPayload,
} from '@quilla-be-kit/security';
// --- Consumer-owned TokenService (example: jose) ---
import { SignJWT, jwtVerify, type JWTPayload } from 'jose';
const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
const toClaims = (payload: SignTokenPayload): TokenClaims => ({
u: payload.userId,
si: payload.scopeId,
st: payload.securityStamp,
...(payload.scopes ? { s: payload.scopes } : {}),
});
const fromClaims = (claims: TokenClaims): SignTokenPayload => ({
userId: claims.u,
scopeId: claims.si,
securityStamp: claims.st,
scopes: claims.s,
});
const jwtTokenService: TokenService = {
async sign(payload: SignTokenPayload, options: { expiresIn: number }): Promise<string> {
return new SignJWT({ ...toClaims(payload) })
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
.setIssuedAt()
.setExpirationTime(Math.floor(Date.now() / 1000) + options.expiresIn)
.sign(secret);
},
async verify(raw: string): Promise<Token> {
const { payload } = await jwtVerify(raw, secret);
const decoded = fromClaims(payload as unknown as TokenClaims);
const issuedAt = new Date((payload.iat as number) * 1000);
const expiresAt = new Date((payload.exp as number) * 1000);
return {
...decoded,
issuedAt,
expiresAt,
isExpired(now = new Date()): boolean {
return now >= expiresAt;
},
};
},
};
// --- Consumer-owned SessionStore (example: Redis via ioredis) ---
import Redis from 'ioredis';
const redis = new Redis();
const sessionStore: SessionStore = {
async set(userId, data, ttlSeconds) {
await redis.setex(`session:${userId}`, ttlSeconds, JSON.stringify(data));
},
async get(userId) {
const raw = await redis.get(`session:${userId}`);
return raw ? JSON.parse(raw) : null;
},
async delete(userId) {
await redis.del(`session:${userId}`);
},
};
// --- Wire into Router ---
const provider = new AsyncExecutionContextProvider();
const router = new Router({
executionContext: { provider },
authStacks: {
bearer: {
credentialVerification: bearerTokenMiddleware({ tokenService: jwtTokenService }),
sessionLoad: authenticatedSessionMiddleware({
sessionStore,
executionContextProvider: provider,
}),
},
},
defaultAuthStack: 'bearer',
modules: registry.getAll(),
});
const server = new HonoServer({ port: 3000, router, serve });Middleware chain
On a non-public route, Router runs the route's resolved auth stack:
system executionContext bootstrap (http-owned)
→ globalMiddlewares[]
→ bearerTokenMiddleware (security — credentialVerification phase)
→ authenticatedSessionMiddleware (security — sessionLoad phase, if present)
→ route middlewares
→ handlerWhich stack a route resolves to is decided by @Get(path, { authStack }) ??
@Controller(prefix, { authStack }) ?? HttpModuleMeta.authStack ??
defaultAuthStack — see the @quilla-be-kit/http README. A single-audience
service declares one stack and never thinks about it again.
On a *Public route, the auth stack is skipped entirely. The system execution-context bootstrap always runs, so even public handlers can call provider.getContext().
The typed AuthMiddlewareStack shape (from @quilla-be-kit/http) enforces phase ordering at the type level — credentialVerification runs before sessionLoad regardless of how keys are declared. The phase is named for the credential rather than the token because a stack may verify a JWT, an opaque token, an API key, or a client certificate; the attribute it populates keeps the VERIFIED_TOKEN name.
Middleware options
Both middlewares take a single minimal options bag:
type BearerTokenMiddlewareOptions = {
readonly tokenService: TokenService;
};
type AuthenticatedSessionMiddlewareOptions = {
readonly sessionStore: SessionStore;
readonly executionContextProvider: ExecutionContextProvider;
};No header override, no timeout, no key prefix — all of those are
implementation choices inside your TokenService / SessionStore
adapters (where they belong). If you need a different token header or
scheme, swap the whole middleware — see "Custom token schemes" below.
Where implementations live
The interfaces (TokenService, SessionStore, PasswordHasher) belong
in your consumer project, not in @quilla-be-kit/security. Typical
placement:
src/
├── security/
│ ├── jwt-token-service.ts // implements TokenService (jose / jsonwebtoken)
│ ├── redis-session-store.ts // implements SessionStore (ioredis / upstash)
│ └── argon2-password-hasher.ts // implements PasswordHasher (@node-rs/argon2)
└── composition-root.ts // wires middlewares with the implementations aboveThe composition root is where you inject your concrete adapters into
bearerTokenMiddleware({ tokenService }) and
authenticatedSessionMiddleware({ sessionStore, executionContextProvider }),
then pass the pair to new Router({ authStacks: { bearer: { ... } }, defaultAuthStack: 'bearer' }).
Custom credential schemes
Write your own credentialVerification middleware to support a mechanism other than Bearer — an API key header, an mTLS client cert, OAuth introspection. The only contract it must honor: on success, populate HttpAttributes.VERIFIED_TOKEN with something satisfying AuthenticatedToken (for @AuthorizeScope) — typically a full Token, so a session-load phase can load the matching session.
import type { HttpMiddleware } from '@quilla-be-kit/http';
import { HttpAttributes } from '@quilla-be-kit/http';
import { UnauthorizedError } from '@quilla-be-kit/errors';
const apiKeyVerification: HttpMiddleware = async (request, next) => {
const key = request.getHeader('x-api-key');
if (!key || !(await isValid(key))) {
throw new UnauthorizedError({ message: 'Invalid API key' });
}
request.setAttribute(HttpAttributes.VERIFIED_TOKEN, await loadTokenForKey(key));
await next();
};If the service authenticates only this way, declare it as the single stack. If it serves two audiences — human routes on JWTs plus a bounded family of machine routes on API keys — declare both and select per route. The two never interact: a route runs exactly one stack.
const router = new Router({
executionContext: { provider },
authStacks: {
bearer: {
credentialVerification: bearerTokenMiddleware({ tokenService }),
sessionLoad: authenticatedSessionMiddleware({ sessionStore, executionContextProvider: provider }),
},
apiKey: {
credentialVerification: apiKeyVerification,
sessionLoad: machineSessionLoad, // yours — see the two warnings below
},
},
defaultAuthStack: 'bearer',
modules: registry.getAll(),
});@Controller('/mcp', { authStack: 'apiKey' })
class McpController {
@Get('/tools')
async tools(req: HttpRequest) { ... }
}Everything else composes unchanged: @AuthorizeScope reads VERIFIED_TOKEN without caring which stack populated it, and HttpAttributes.AUTH_STACK carries the resolved stack name if a guard needs to assert which one authenticated the caller.
Each stack needs its own sessionLoad
Do not reuse authenticatedSessionMiddleware for a second stack. It keys the store on token.userId alone and hardcodes actorType: 'user'. If a machine credential resolves to an existing user:
- the machine has no session record of its own — it collides with the human's;
- a human logging out (
sessionStore.delete(userId)) silently revokes machine access, and asecurityStamprotation locks the machine out; - the context can never carry
actorType: 'machine'.
Write a sessionLoad that checks that credential's revocation source — its own expiry and revoked flag — and enters a nested runWithContext with its own actorType. It must populate ExecutionContext.session (scopeId + userId): @ValidateRequest injects auth-derived scopeId/userId only when session is present, so a stack that sets actorType but omits session fails open — the handler runs with an undefined scope rather than being rejected.
Stacks must not share a credential verifier or signing key
TokenService.verify(token) takes no audience, and TokenClaims carries no aud, so a credential is verifiable by any verifier holding the same key. Per-route stack selection is the only thing keeping audiences apart — sharing a verifier between two stacks collapses that boundary, and unlike the revocation trap above it fails open. Give each stack its own verifier and key material. Scope strings must likewise be globally unique across stacks, since @AuthorizeScope reads one flat token.scopes namespace.
Session revocation
securityStamp is the revocation mechanism. Rotate the stored stamp whenever the session should be invalidated:
- Explicit logout →
sessionStore.delete(userId). - Password change / force revoke → rotate
securityStampin your user record andsessionStore.set(userId, { ...session, securityStamp: newStamp }, ttl).
The next request with a token carrying the old stamp fails authenticatedSessionMiddleware's stamp comparison and is rejected as UnauthorizedError — without any change to the token itself.
This mechanism belongs to the bearer stack. Revocation is per stack: another stack's sessionLoad checks its own source (an API key's revoked flag and expiry, a certificate revocation list), and must not route through the same SessionStore — see Each stack needs its own sessionLoad.
Role as the rule-of-three harness
@quilla-be-kit/security is the first consumer that exercises every other quilla-be-kit package in concert:
@quilla-be-kit/errors—UnauthorizedErrorsurfaces through the http error handler.@quilla-be-kit/execution-context— enriched context propagates through AsyncLocalStorage to downstream code.@quilla-be-kit/http—HttpRequest/HttpMiddleware/HttpAttributes/AuthenticatedToken/AuthMiddlewareStackare all load-bearing here.@quilla-be-kit/observability— theUnauthorizedErrorcausechain preserves the underlying verification failure for structured logs.
If these primitives can be composed into a clean auth module without bending any quilla-be-kit interface, the substrate is right. The moment it starts bending, we catch it here.
Status
Interface + middleware surface implemented. No bundled TokenService, SessionStore, or PasswordHasher adapters — by design.
