@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 AuthEnv and CorsEnv with your service's own bindings and pass the combined type as the factory's type parameter.
  • Add capability strings to JWTClaims.claims at token-issue time; createAdminAuthMiddleware checks for the literal 'admin' claim, and handlers can read c.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.
  • expiresInSeconds on generateToken controls token lifetime per call site; DEFAULT_TOKEN_TTL_SECONDS is the 30-day default.
  • Pass jti and tokenType: 'service' to generateToken when the issuer keeps a revocation record for the token, as identity-service does for service tokens.
  • createCorsMiddleware takes the service's own allowHeaders/exposeHeaders; isOriginAllowed is 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 local auth.ts copy is deduped against this package in a later phase; formats must stay byte-compatible)
  • progress-tracker-api (Phase 4)