@game-infra/shared-be
v0.4.0
Published
JWT (Web Crypto) + Hono auth middleware for Cloudflare Workers
Readme
@game-infra/shared-be
JWT signing/verification and password hashing built on the Web Crypto API, plus origin-allowlist CORS and Hono auth middleware, for Cloudflare Workers services. Tokens are HS256; passwords are stored as salt:sha256hex (bcrypt-style algorithms are not practical inside Workers). The wire formats are shared with existing user records and issued tokens, so they are frozen: do not change claim names, the salt:hash layout, or any encoding.
verifyToken fails closed. It rejects a token whose header does not declare HS256, whose payload is missing userId, username, or a numeric exp, or whose exp has passed. There is no revocation list in this package, so expiry is the only way a token is ever withdrawn here — a token without one would be valid forever.
Tokens may additionally carry jti (a token id) and tokenType ('user' or 'service'). Both are optional and absent from every token issued before service tokens existed, so an absent tokenType reads as 'user'. jti is what lets an issuer revoke a token it never stored: identity-service keeps the id and the token carries it, so revoking is a write against the id rather than a lookup of the secret. Checking the revocation list is the issuer's job — verifyToken still only proves signature and expiry.
Linking
This package is consumed as source from a sibling game-infra checkout, not from npm.
pnpm:
{
"dependencies": {
"@game-infra/shared-be": "link:../../../game-infra/packages/shared-be"
}
}npm:
{
"dependencies": {
"@game-infra/shared-be": "file:../../../game-infra/packages/shared-be"
}
}hono is a peer dependency (^4.12.0, mirrored in COMPAT.json); the consuming service provides it.
Usage
Sign and verify a JWT
import { generateToken, verifyToken } from "@game-infra/shared-be";
// Login endpoint: issue a token (expires in 30 days by default)
const token = await generateToken(
{ userId: user.id, username: user.username, claims: ["admin"] },
c.env.JWT_SECRET,
);
// Later: verify it. Returns null for invalid or expired tokens, never throws.
const payload = await verifyToken(token, c.env.JWT_SECRET);
if (!payload) {
return c.json({ error: "invalid token" }, 401);
}
console.log(payload.userId, payload.username, payload.claims);generateToken takes JWTClaims (no exp) and stamps the expiry itself, so a
call site cannot mint a longer-lived token by passing one in. verifyToken
returns JWTPayload, which is JWTClaims plus the exp it checked.
Restrict cross-origin browser callers
import { createCorsMiddleware, type CorsEnv } from "@game-infra/shared-be";
import { Hono } from "hono";
interface Env extends CorsEnv {}
const app = new Hono<{ Bindings: Env }>();
// Mount before route registration: preflight OPTIONS requests match no route.
app.use("*", createCorsMiddleware<Env>({ allowHeaders: ["Content-Type", "Authorization"] }));The allowlist is configuration, not code: CORS_ALLOWED_HOSTS is a comma-separated list of host suffixes (example.com also allows assets.example.com, but never notexample.com), and CORS_ALLOW_LOCAL_ORIGINS="true" additionally trusts loopback and private-LAN origins. Set the latter in development configs only.
Hash and check passwords
import { hashPassword, verifyPassword } from "@game-infra/shared-be";
// Registration: store the salt:hash string
const passwordHash = await hashPassword(body.password);
// Login: check the plaintext against the stored hash
const ok = await verifyPassword(body.password, user.passwordHash);Wire the middleware into a Hono app
import {
createAdminAuthMiddleware,
createOptionalUserAuthMiddleware,
createUserAuthMiddleware,
type AuthEnv,
type UserContext,
} from "@game-infra/shared-be";
import { Hono } from "hono";
interface Env extends AuthEnv {
DB: D1Database;
}
const app = new Hono<{ Bindings: Env; Variables: UserContext }>();
app.use("/api/*", createUserAuthMiddleware<Env>());
app.use("/leaderboard", createOptionalUserAuthMiddleware<Env>());
app.use("/admin/*", createAdminAuthMiddleware<Env>());
app.get("/api/profile", (c) => c.json({ userId: c.get("userId") }));
app.get("/leaderboard", (c) => c.json({ me: c.get("userId") ?? null }));
app.delete("/admin/users/:id", (c) => c.json({ deletedBy: c.get("username") }));
export default app;The middleware reads the secret from the JWT_SECRET binding and takes it from nowhere else. Bind it in every environment, local dev included: with the binding missing or blank, every guarded request answers 500 instead of verifying tokens against a signing key that ships in this package's source.
Extension points
- Extend
AuthEnvandCorsEnvwith your service's own bindings and pass the combined type as the factory's type parameter. - Add capability strings to
JWTClaims.claimsat token-issue time;createAdminAuthMiddlewarechecks for the literal'admin'claim, and handlers can readc.get('claims')to enforce their own. Derive them from stored account state, never from the username string — otherwise whoever registers a privileged-looking name gets the claim. expiresInSecondsongenerateTokencontrols token lifetime per call site;DEFAULT_TOKEN_TTL_SECONDSis the 30-day default.- Pass
jtiandtokenType: 'service'togenerateTokenwhen the issuer keeps a revocation record for the token, asidentity-servicedoes for service tokens. createCorsMiddlewaretakes the service's ownallowHeaders/exposeHeaders;isOriginAllowedis exported for services that need the rule outside middleware.
Dependencies
- Internal: none.
- External:
hono(peer). The crypto primitives come from the Workers runtime (crypto.subtle,btoa/atob).
Consumers
identity-service(JWT + password utilities and CORS)file-storage-service(CORS)core-service(its localauth.tscopy is deduped against this package in a later phase; formats must stay byte-compatible)progress-tracker-api(Phase 4)