first commit
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
# @wrnexus/jwt
|
||||
|
||||
> Dependency-free JSON Web Tokens (HS256) via Web Crypto, plus a bearer-token auth middleware for WrNexus.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
|
||||
## Overview
|
||||
|
||||
`@wrnexus/jwt` signs and verifies stateless JSON Web Tokens using the **HS256**
|
||||
(HMAC-SHA-256) algorithm. It has no runtime dependencies — signing and
|
||||
verification are implemented directly on the standard **Web Crypto** API
|
||||
(`crypto.subtle`), which Bun provides natively. It runs server-side and pairs
|
||||
with the session-based auth in `@wrnexus/core`, giving you a stateless option
|
||||
for API and mobile clients. Reach for it when you need bearer-token auth rather
|
||||
than cookie sessions.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @wrnexus/jwt
|
||||
```
|
||||
|
||||
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
||||
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
|
||||
|
||||
## API
|
||||
|
||||
Single entry point (`@wrnexus/jwt`). All functions are async and return Promises.
|
||||
|
||||
| Export | Kind | Description |
|
||||
| --------------------------------------- | --------- | --------------------------------------------------------------- |
|
||||
| `signJwt(payload, secret, options?)` | function | Sign claims into an HS256 token string. |
|
||||
| `verifyJwt<T>(token, secret, options?)` | function | Verify a token and return its claims, or throw. |
|
||||
| `jwtAuth(options)` | function | Middleware that verifies a bearer JWT and sets `ctx.user`. |
|
||||
| `JwtError` | class | Error thrown on any signature/payload/expiry failure. |
|
||||
| `JwtClaims` | interface | Claims shape (`sub`, `iat`, `exp`, `nbf`, plus arbitrary keys). |
|
||||
| `SignOptions` | interface | Options for `signJwt`. |
|
||||
| `JwtAuthOptions` | interface | Options for `jwtAuth`. |
|
||||
|
||||
### `signJwt(payload, secret, options?)`
|
||||
|
||||
```ts
|
||||
function signJwt(payload: JwtClaims, secret: string, options?: SignOptions): Promise<string>;
|
||||
```
|
||||
|
||||
Signs `payload` with `secret` using HS256 and returns the encoded token
|
||||
(`header.body.signature`). An `iat` (issued-at) claim is always added.
|
||||
|
||||
`SignOptions`:
|
||||
|
||||
- `expiresIn?: number` — seconds until expiry; sets the `exp` claim.
|
||||
- `now?: number` — override the issued-at time (seconds), useful for testing.
|
||||
|
||||
### `verifyJwt<T>(token, secret, options?)`
|
||||
|
||||
```ts
|
||||
function verifyJwt<T extends JwtClaims = JwtClaims>(
|
||||
token: string,
|
||||
secret: string,
|
||||
options?: { now?: number },
|
||||
): Promise<T>;
|
||||
```
|
||||
|
||||
Verifies the HS256 signature and returns the decoded claims typed as `T`.
|
||||
Throws `JwtError` when the token is malformed, the signature is invalid, the
|
||||
payload is not valid JSON, the token is expired (`exp`), or not yet valid
|
||||
(`nbf`). Pass `now` (seconds) to override the reference time for the `exp`/`nbf`
|
||||
checks.
|
||||
|
||||
### `jwtAuth(options)`
|
||||
|
||||
```ts
|
||||
function jwtAuth(options: JwtAuthOptions): Middleware;
|
||||
```
|
||||
|
||||
Returns a WrNexus `Middleware` that reads a token, verifies it, and assigns the
|
||||
claims to `ctx.user`.
|
||||
|
||||
`JwtAuthOptions`:
|
||||
|
||||
- `secret: string` — the HMAC secret used to verify tokens.
|
||||
- `getToken?: (ctx: Context) => string | undefined` — how to extract the token.
|
||||
Defaults to reading `Authorization: Bearer <token>`.
|
||||
- `required?: boolean` — when `true` (default), a missing or invalid token
|
||||
responds with `401 { ok: false, error: "Unauthorized" }`. When `false`,
|
||||
requests pass through and `ctx.user` is only set if a valid token is present.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { signJwt, verifyJwt, jwtAuth, JwtError } from "@wrnexus/jwt";
|
||||
|
||||
const secret = process.env.JWT_SECRET!;
|
||||
|
||||
// Sign a token that expires in one hour
|
||||
const token = await signJwt({ sub: user.id, role: "admin" }, secret, {
|
||||
expiresIn: 3600,
|
||||
});
|
||||
|
||||
// Verify it later
|
||||
try {
|
||||
const claims = await verifyJwt<{ sub: string; role: string }>(token, secret);
|
||||
console.log(claims.sub, claims.role);
|
||||
} catch (err) {
|
||||
if (err instanceof JwtError) {
|
||||
// invalid signature, expired, malformed, etc.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Protecting routes with the middleware:
|
||||
|
||||
```ts
|
||||
import { jwtAuth } from "@wrnexus/jwt";
|
||||
|
||||
// Require a valid bearer token; ctx.user holds the verified claims
|
||||
app.use(jwtAuth({ secret: process.env.JWT_SECRET! }));
|
||||
|
||||
// Optional auth — populate ctx.user when present, but don't 401
|
||||
app.use(jwtAuth({ secret: process.env.JWT_SECRET!, required: false }));
|
||||
```
|
||||
|
||||
## Requirements / Notes
|
||||
|
||||
- **Bun-only.** Uses the standard Web Crypto API (`crypto.subtle.importKey`,
|
||||
`sign`, `verify`) plus `btoa`/`atob` and `TextEncoder`/`TextDecoder` — all
|
||||
provided by Bun. No third-party crypto dependency.
|
||||
- **Algorithm:** HS256 (HMAC with SHA-256) only. Asymmetric algorithms (RS/ES)
|
||||
are not supported.
|
||||
- Integrates with [`@wrnexus/core`](../core) for `Context`, `Middleware`, and
|
||||
`ctx.user`; it complements the framework's cookie/session auth with a
|
||||
stateless bearer-token flow for API and mobile clients.
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "@wrnexus/jwt",
|
||||
"version": "0.2.12",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* @wrnexus/jwt — dependency-free JSON Web Tokens (HS256) via WebCrypto, plus a
|
||||
* bearer-token auth middleware. Pairs with the session auth in @wrnexus/core for
|
||||
* stateless (API/mobile) authentication.
|
||||
*
|
||||
* const token = await signJwt({ sub: user.id, role: "admin" }, secret, { expiresIn: 3600 });
|
||||
* const claims = await verifyJwt(token, secret); // throws JwtError if invalid/expired
|
||||
*/
|
||||
|
||||
import type { Context, Middleware } from "@wrnexus/core";
|
||||
|
||||
export class JwtError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "JwtError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface JwtClaims {
|
||||
/** Subject (user id). */
|
||||
sub?: string;
|
||||
/** Issued-at (seconds). */
|
||||
iat?: number;
|
||||
/** Expiry (seconds). */
|
||||
exp?: number;
|
||||
/** Not-before (seconds). */
|
||||
nbf?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SignOptions {
|
||||
/** Seconds until expiry (sets `exp`). */
|
||||
expiresIn?: number;
|
||||
/** Override issued-at (seconds). */
|
||||
now?: number;
|
||||
}
|
||||
|
||||
const enc = new TextEncoder();
|
||||
const MAX_CACHED_KEYS = 32;
|
||||
const keyCache = new Map<string, Promise<CryptoKey>>();
|
||||
|
||||
function b64urlEncode(bytes: Uint8Array): string {
|
||||
let bin = "";
|
||||
for (const b of bytes) bin += String.fromCharCode(b);
|
||||
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
function b64urlDecode(str: string): Uint8Array {
|
||||
const pad = str.length % 4 === 0 ? "" : "=".repeat(4 - (str.length % 4));
|
||||
const bin = atob(str.replace(/-/g, "+").replace(/_/g, "/") + pad);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function hmacKey(secret: string): Promise<CryptoKey> {
|
||||
const cached = keyCache.get(secret);
|
||||
if (cached) {
|
||||
// Refresh insertion order so frequently used secrets stay hot.
|
||||
keyCache.delete(secret);
|
||||
keyCache.set(secret, cached);
|
||||
return cached;
|
||||
}
|
||||
const imported = crypto.subtle.importKey(
|
||||
"raw",
|
||||
enc.encode(secret) as BufferSource,
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign", "verify"],
|
||||
);
|
||||
keyCache.set(secret, imported);
|
||||
if (keyCache.size > MAX_CACHED_KEYS) keyCache.delete(keyCache.keys().next().value!);
|
||||
try {
|
||||
return await imported;
|
||||
} catch (error) {
|
||||
keyCache.delete(secret);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Sign a payload into a JWT (HS256). */
|
||||
export async function signJwt(
|
||||
payload: JwtClaims,
|
||||
secret: string,
|
||||
options: SignOptions = {},
|
||||
): Promise<string> {
|
||||
const now = options.now ?? Math.floor(Date.now() / 1000);
|
||||
const claims: JwtClaims = { iat: now, ...payload };
|
||||
if (options.expiresIn !== undefined) claims.exp = now + options.expiresIn;
|
||||
|
||||
const header = b64urlEncode(enc.encode(JSON.stringify({ alg: "HS256", typ: "JWT" })));
|
||||
const body = b64urlEncode(enc.encode(JSON.stringify(claims)));
|
||||
const data = `${header}.${body}`;
|
||||
const sig = new Uint8Array(
|
||||
await crypto.subtle.sign("HMAC", await hmacKey(secret), enc.encode(data) as BufferSource),
|
||||
);
|
||||
return `${data}.${b64urlEncode(sig)}`;
|
||||
}
|
||||
|
||||
/** Verify a JWT and return its claims. Throws `JwtError` on any failure. */
|
||||
export async function verifyJwt<T extends JwtClaims = JwtClaims>(
|
||||
token: string,
|
||||
secret: string,
|
||||
options: { now?: number } = {},
|
||||
): Promise<T> {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) throw new JwtError("Malformed token");
|
||||
const [header, body, sig] = parts as [string, string, string];
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(new TextDecoder().decode(b64urlDecode(header))) as {
|
||||
alg?: unknown;
|
||||
typ?: unknown;
|
||||
};
|
||||
if (parsed.alg !== "HS256" || (parsed.typ !== undefined && parsed.typ !== "JWT")) {
|
||||
throw new JwtError("Unsupported token header");
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof JwtError) throw error;
|
||||
throw new JwtError("Invalid token header");
|
||||
}
|
||||
|
||||
let valid: boolean;
|
||||
try {
|
||||
valid = await crypto.subtle.verify(
|
||||
"HMAC",
|
||||
await hmacKey(secret),
|
||||
b64urlDecode(sig) as BufferSource,
|
||||
enc.encode(`${header}.${body}`) as BufferSource,
|
||||
);
|
||||
} catch {
|
||||
throw new JwtError("Invalid signature");
|
||||
}
|
||||
if (!valid) throw new JwtError("Invalid signature");
|
||||
|
||||
let claims: T;
|
||||
try {
|
||||
claims = JSON.parse(new TextDecoder().decode(b64urlDecode(body))) as T;
|
||||
} catch {
|
||||
throw new JwtError("Invalid payload");
|
||||
}
|
||||
const now = options.now ?? Math.floor(Date.now() / 1000);
|
||||
if (typeof claims.exp === "number" && now >= claims.exp) throw new JwtError("Token expired");
|
||||
if (typeof claims.nbf === "number" && now < claims.nbf) throw new JwtError("Token not yet valid");
|
||||
return claims;
|
||||
}
|
||||
|
||||
export interface JwtAuthOptions {
|
||||
secret: string;
|
||||
/** Where to read the token. Default: `Authorization: Bearer <token>`. */
|
||||
getToken?: (ctx: Context) => string | undefined;
|
||||
/** Reject unauthenticated requests with 401. Default true. */
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware that verifies a bearer JWT and sets `ctx.user` to its claims.
|
||||
* When `required` (default), a missing/invalid token gets a 401.
|
||||
*/
|
||||
export function jwtAuth(options: JwtAuthOptions): Middleware {
|
||||
const getToken = options.getToken ?? bearerToken;
|
||||
const required = options.required !== false;
|
||||
return async (ctx, next) => {
|
||||
const token = getToken(ctx);
|
||||
if (token) {
|
||||
try {
|
||||
ctx.user = await verifyJwt(token, options.secret);
|
||||
} catch {
|
||||
if (required) return unauthorized();
|
||||
}
|
||||
} else if (required) {
|
||||
return unauthorized();
|
||||
}
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
function bearerToken(ctx: Context): string | undefined {
|
||||
const header = ctx.req.headers.get("authorization") ?? "";
|
||||
const m = /^Bearer\s+(.+)$/i.exec(header);
|
||||
return m ? m[1] : undefined;
|
||||
}
|
||||
|
||||
function unauthorized(): Response {
|
||||
return Response.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { createContext } from "@wrnexus/core";
|
||||
import { signJwt, verifyJwt, jwtAuth, JwtError } from "../src/index.ts";
|
||||
|
||||
const SECRET = "test-secret-key";
|
||||
|
||||
test("sign + verify round-trip preserves claims", async () => {
|
||||
const token = await signJwt({ sub: "u1", role: "admin" }, SECRET);
|
||||
const claims = await verifyJwt(token, SECRET);
|
||||
expect(claims.sub).toBe("u1");
|
||||
expect(claims.role).toBe("admin");
|
||||
expect(typeof claims.iat).toBe("number");
|
||||
});
|
||||
|
||||
test("tampering or wrong secret fails verification", async () => {
|
||||
const token = await signJwt({ sub: "u1" }, SECRET);
|
||||
await expect(verifyJwt(token, "other-secret")).rejects.toThrow(JwtError);
|
||||
const tampered = token.slice(0, -2) + (token.endsWith("a") ? "bb" : "aa");
|
||||
await expect(verifyJwt(tampered, SECRET)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("rejects a signed token whose header declares another algorithm", async () => {
|
||||
const token = await signJwt({ sub: "u1" }, SECRET);
|
||||
const [, body] = token.split(".");
|
||||
const header = btoa(JSON.stringify({ alg: "none", typ: "JWT" }))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
await expect(verifyJwt(`${header}.${body}.invalid`, SECRET)).rejects.toThrow(
|
||||
"Unsupported token header",
|
||||
);
|
||||
});
|
||||
|
||||
test("expiry is enforced", async () => {
|
||||
const token = await signJwt({ sub: "u1" }, SECRET, { expiresIn: 100, now: 1000 });
|
||||
expect((await verifyJwt(token, SECRET, { now: 1050 })).sub).toBe("u1"); // still valid
|
||||
await expect(verifyJwt(token, SECRET, { now: 1200 })).rejects.toThrow("expired");
|
||||
});
|
||||
|
||||
function ctxWith(auth?: string) {
|
||||
const url = new URL("http://x/api/me");
|
||||
return createContext(new Request(url, { headers: auth ? { authorization: auth } : {} }), url);
|
||||
}
|
||||
|
||||
test("jwtAuth sets ctx.user for a valid bearer token", async () => {
|
||||
const token = await signJwt({ sub: "u9" }, SECRET);
|
||||
const ctx = ctxWith(`Bearer ${token}`);
|
||||
const res = await jwtAuth({ secret: SECRET })(ctx, () => new Response("ok"));
|
||||
expect(res.status).toBe(200);
|
||||
expect((ctx.user as { sub: string }).sub).toBe("u9");
|
||||
});
|
||||
|
||||
test("jwtAuth 401s a missing/invalid token when required", async () => {
|
||||
const missing = await jwtAuth({ secret: SECRET })(ctxWith(), () => new Response("ok"));
|
||||
expect(missing.status).toBe(401);
|
||||
const bad = await jwtAuth({ secret: SECRET })(ctxWith("Bearer nope"), () => new Response("ok"));
|
||||
expect(bad.status).toBe(401);
|
||||
});
|
||||
|
||||
test("jwtAuth optional mode passes through anonymously", async () => {
|
||||
const ctx = ctxWith();
|
||||
const res = await jwtAuth({ secret: SECRET, required: false })(ctx, () => new Response("ok"));
|
||||
expect(res.status).toBe(200);
|
||||
expect(ctx.user).toBeUndefined();
|
||||
});
|
||||
Reference in New Issue
Block a user