@ayepi/auth
Authentication middleware for @ayepi/core:
Bearer (JWT, HS256) and Basic auth, fully typed, each contributing its own OpenAPI
security scheme. JWT crypto is dependency-free (node:crypto, HS256 only).
Each guard is split into a def (a frontend-safe contract you declare in your spec) and an impl (the server-side binding that supplies secrets and crypto):
@ayepi/auth— frontend-safe def factoriesbearerAuth/basicAuth. No secrets, nonode:crypto. Safe to import from a spec a browser build consumes.@ayepi/auth/server— the samebearerAuth/basicAuthaugmented with a.server(def, cfg)binder, plus thesignJwt/verifyJwt/JwtErrorprimitives. This is the only entry that pulls innode:crypto; keep it out of the frontend bundle.
pnpm add @ayepi/auth @ayepi/core zodBearer (JWT)
In your shared (frontend-safe) module you declare the def with explicit Claims / User
type args, then build the spec around it:
import { z } from 'zod'
import { spec } from '@ayepi/core'
import { bearerAuth } from '@ayepi/auth'
const Claims = z.object({ userId: z.string(), role: z.enum(['admin', 'member']) })
type Claims = z.infer<typeof Claims>
type User = { id: string; role: string }
const auth = bearerAuth<Claims, User>() // def only — no secret, frontend-safe
export const api = spec({ endpoints: { ...auth.group({ me: { response: UserOut } }) } })On the server you bind the impl via bearerAuth.server(def, cfg) and attach it with
implement(api).middleware(...). The binder reads User / Claims from the def, so the
config stays type-aligned with the contract:
import { implement, server } from '@ayepi/core'
import { bearerAuth } from '@ayepi/auth/server'
const app = server(api, [
implement(api)
.middleware(bearerAuth.server(auth, {
secret: process.env.JWT_SECRET!,
claims: Claims,
toUser: (c) => db.users.find(c.userId), // null/undefined ⇒ 401
expiresIn: 900,
issuer: 'my-api',
}))
.handlers({
me: ({ user, jwt, signToken }) => {
const { token } = signToken({ userId: user.id, role: 'admin' }, { expiresIn: 60 })
return { id: user.id, role: user.role }
},
}),
])The middleware verifies the HS256 signature + exp/nbf (with tolerance) + iss/aud,
validates the custom claims, and resolves a user — then the handler gets { user, jwt, signToken }.
signToken mints a fresh HS256 token (applying the configured secret/issuer/audience and
expiry default, with an optional per-call expiresIn override) and returns the token plus
the full decoded payload. jwt is the full payload: custom claims ∪ the registered claims
(iss / sub / aud / exp / nbf / iat / jti).
Failures (missing/malformed header, bad signature, expired, claim mismatch, nullish/throwing
toUser) short-circuit with 401 + WWW-Authenticate: Bearer.
Basic
The def is frontend-safe; the credential check is bound server-side:
// shared.ts (frontend-safe)
import { basicAuth } from '@ayepi/auth'
const auth = basicAuth<{ id: string }>()
export const api = spec({ endpoints: { ...auth.group({ stats: { response: StatsOut } }) } })
// server.ts
import { basicAuth } from '@ayepi/auth/server'
implement(api).middleware(basicAuth.server(auth, {
realm: 'Admin',
verify: (user, pass) => (user === 'root' && pass === env.PW ? { id: 'root' } : null),
}))The handler gets { user }. Bad/missing/non-Basic credentials (or a nullish/throwing
verify) yield 401 + WWW-Authenticate: Basic realm="…".
Standalone JWT utils
The HS256 primitives the bearer middleware is built on, usable anywhere — imported from the
/server entry (they pull in node:crypto):
import { signJwt, verifyJwt, JwtError } from '@ayepi/auth/server'
const { token, payload } = signJwt({ userId: 'u1' }, { secret, expiresIn: 900, issuer: 'api' })
const claims = verifyJwt<{ userId: string }>(token, { secret, issuer: 'api', clockToleranceSec: 5 })
// verifyJwt throws JwtError on malformed / mis-signed / expired / claim-mismatch tokenssignJwt sets iat to now and exp to iat + (expiresIn ?? 3600). verifyJwt checks
structure, the HS256 alg, the signature (constant-time), then exp/nbf/iss/aud.
OpenAPI security docs
Each def contributes a scheme automatically (the contract lives on the def, so the docs are available frontend-side too):
const doc = app.openapi({ title: 'API', version: '1.0.0' })
doc.components.securitySchemes.bearerAuth // { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }
doc.components.securitySchemes.basicAuth // { type: 'http', scheme: 'basic' }
doc.paths['/me'].post.security // [{ bearerAuth: [] }]Notes
- Def vs. impl. A spec that imports
@ayepi/authis frontend-safe — the.serverbinder and JWT crypto stay in@ayepi/auth/server, out of the browser bundle. - HS256 only,
node:cryptoonly — no external JWT library. - Keep
secretserver-side; rotating it invalidates existing tokens (no key-id window). - Your
claimsschema describes only custom claims — registered claims are handled for you.
For the full reference (typed context, every rejection branch, gotchas), see
ayepi-auth.md and ayepi-core-middleware.md.
For AI coding agents
This package ships dense, machine-oriented reference docs written for AI coding agents (Claude Code, Cursor, and the like) to understand and drive the package — point your agent at them:
They ship with this package and also live in the repo.
License
MIT Philip Diffenderfer