254 lines
7.6 KiB
TypeScript
254 lines
7.6 KiB
TypeScript
/**
|
|
* @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<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;
|
|
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<T extends JwtClaims = JwtClaims>(
|
|
token: string,
|
|
secret: string,
|
|
options: VerifyOptions = {},
|
|
): 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);
|
|
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 <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 });
|
|
}
|
|
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";
|