/** * @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; issuer?: string; audience?: string | string[]; jwtId?: string; /** Key identifier placed in the protected header. */ keyId?: string; } export interface VerifyOptions { now?: number; clockTolerance?: number; issuer?: string; audience?: string | string[]; maxAge?: number; } const enc = new TextEncoder(); const MAX_CACHED_KEYS = 32; const keyCache = new Map>(); 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 { 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 { const now = options.now ?? Math.floor(Date.now() / 1000); const claims: JwtClaims = { iat: now, ...payload }; if (options.expiresIn !== undefined) claims.exp = now + options.expiresIn; if (options.issuer !== undefined) claims.iss = options.issuer; if (options.audience !== undefined) claims.aud = options.audience; if (options.jwtId !== undefined) claims.jti = options.jwtId; const header = b64urlEncode( enc.encode( JSON.stringify({ alg: "HS256", typ: "JWT", ...(options.keyId ? { kid: options.keyId } : {}), }), ), ); 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( token: string, secret: string, options: VerifyOptions = {}, ): Promise { 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); const tolerance = Math.max(0, options.clockTolerance ?? 0); if (typeof claims.exp === "number" && now - tolerance >= claims.exp) throw new JwtError("Token expired"); if (typeof claims.nbf === "number" && now + tolerance < claims.nbf) throw new JwtError("Token not yet valid"); if ( options.maxAge !== undefined && typeof claims.iat === "number" && now - claims.iat > options.maxAge + tolerance ) { throw new JwtError("Token is too old"); } if (options.issuer !== undefined && claims.iss !== options.issuer) throw new JwtError("Invalid issuer"); if (options.audience !== undefined) { const expected = Array.isArray(options.audience) ? options.audience : [options.audience]; const actual = Array.isArray(claims.aud) ? claims.aud : typeof claims.aud === "string" ? [claims.aud] : []; if (!expected.some((audience) => actual.includes(audience))) throw new JwtError("Invalid audience"); } return claims; } export interface JwtAuthOptions { secret: string; /** Where to read the token. Default: `Authorization: Bearer `. */ 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 }); } export { decodeJwt, createJwtKeyring, signWithKeyring, verifyWithKeyring } from "./keyring.ts"; export type { JwtKey, JwtKeyring } from "./keyring.ts"; export { extractBearerToken, tryVerifyJwt, assertJwtClaims, tokenScopes, hasScopes, requireScopes, createAccessToken, createRefreshToken, verifyAccessToken, verifyRefreshToken, readJwtCookie, jwtCookie, clearJwtCookie, createTokenPair, jwtResponse, } from "./helpers.ts"; export type { AccessTokenClaims, RefreshTokenClaims, JwtTokenPair } from "./helpers.ts"; export { createRemoteJwks, verifyJwtWithJwks } from "./jwks.ts"; export type { RemoteJwks, RemoteJwksOptions } from "./jwks.ts";