release: WRNexusJS 0.8.0
This commit is contained in:
@@ -130,3 +130,62 @@ app.use(jwtAuth({ secret: process.env.JWT_SECRET!, required: false }));
|
||||
- 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.
|
||||
|
||||
## Access, refresh, scope, and cookie helpers
|
||||
|
||||
```ts
|
||||
import {
|
||||
createAccessToken,
|
||||
createRefreshToken,
|
||||
verifyAccessToken,
|
||||
verifyRefreshToken,
|
||||
extractBearerToken,
|
||||
requireScopes,
|
||||
jwtCookie,
|
||||
} from "@wrnexus/jwt";
|
||||
```
|
||||
|
||||
The helpers add explicit `type: "access" | "refresh"` claims, scope checks, refresh-token family metadata, no-store token responses, and secure cookie defaults. `__Host-` cookies are rejected unless they use `Path=/` and `Secure`; `SameSite=None` is rejected without `Secure`.
|
||||
|
||||
## 0.8 helper kit
|
||||
|
||||
```ts
|
||||
import {
|
||||
createTokenPair,
|
||||
verifyAccessToken,
|
||||
verifyRefreshToken,
|
||||
extractBearerToken,
|
||||
readJwtCookie,
|
||||
jwtCookie,
|
||||
clearJwtCookie,
|
||||
requireScopes,
|
||||
} from "@wrnexus/jwt";
|
||||
|
||||
const pair = await createTokenPair(user.id, {
|
||||
accessSecret: process.env.JWT_ACCESS_SECRET!,
|
||||
refreshSecret: process.env.JWT_REFRESH_SECRET!,
|
||||
scopes: ["profile:read"],
|
||||
family: sessionFamily,
|
||||
});
|
||||
```
|
||||
|
||||
The helper kit validates `__Host-` cookie invariants, cookie names and paths, `SameSite=None` security, typed access/refresh token types, scope requirements, and no-store token responses.
|
||||
In addition to local HS256 secrets/keyrings, the package verifies standards-based
|
||||
RS256 tokens through bounded remote JWKS caches:
|
||||
|
||||
```ts
|
||||
import { createRemoteJwks, verifyJwtWithJwks } from "@wrnexus/jwt";
|
||||
|
||||
const jwks = createRemoteJwks("https://issuer.example/.well-known/jwks.json");
|
||||
const claims = await verifyJwtWithJwks(token, jwks, {
|
||||
issuer: "https://issuer.example",
|
||||
audience: "my-api",
|
||||
maxAge: 300,
|
||||
});
|
||||
```
|
||||
|
||||
JWKS URLs must use HTTPS. Responses have key-count/byte limits, accept only
|
||||
RS256 signing RSA keys, deduplicate concurrent refreshes, cache imported public
|
||||
keys, and force an immediate refresh for an unknown `kid` so issuer rotation
|
||||
does not wait for cache expiry. Never use decoded-but-unverified claims for an
|
||||
authorization decision.
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
{
|
||||
"name": "@wrnexus/jwt",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"main": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"description": "HS256 JSON Web Tokens, key rotation, access/refresh helpers, scopes, cookies, and auth middleware.",
|
||||
"types": "./src/index.ts",
|
||||
"files": [
|
||||
"src",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check": "bun run typecheck && bun run test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"typescript": "^5.9.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import type { Context, Middleware } from "@wrnexus/core";
|
||||
import {
|
||||
JwtError,
|
||||
signJwt,
|
||||
verifyJwt,
|
||||
type JwtClaims,
|
||||
type SignOptions,
|
||||
type VerifyOptions,
|
||||
} from "./index.ts";
|
||||
|
||||
export interface AccessTokenClaims extends JwtClaims {
|
||||
sub: string;
|
||||
type: "access";
|
||||
scopes?: string[];
|
||||
}
|
||||
|
||||
export interface RefreshTokenClaims extends JwtClaims {
|
||||
sub: string;
|
||||
type: "refresh";
|
||||
family?: string;
|
||||
}
|
||||
|
||||
export function extractBearerToken(
|
||||
value: Headers | Request | Context | string | null | undefined,
|
||||
): string | undefined {
|
||||
const header =
|
||||
typeof value === "string" || value == null
|
||||
? (value ?? "")
|
||||
: "req" in value
|
||||
? (value.req.headers.get("authorization") ?? "")
|
||||
: value instanceof Request
|
||||
? (value.headers.get("authorization") ?? "")
|
||||
: (value.get("authorization") ?? "");
|
||||
return /^Bearer\s+(.+)$/i.exec(header)?.[1];
|
||||
}
|
||||
|
||||
export async function tryVerifyJwt<T extends JwtClaims = JwtClaims>(
|
||||
token: string | undefined,
|
||||
secret: string,
|
||||
options: VerifyOptions = {},
|
||||
): Promise<T | null> {
|
||||
if (!token) return null;
|
||||
try {
|
||||
return await verifyJwt<T>(token, secret, options);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function assertJwtClaims<T extends JwtClaims>(
|
||||
claims: T,
|
||||
requirements: {
|
||||
subject?: boolean;
|
||||
type?: string;
|
||||
required?: string[];
|
||||
} = {},
|
||||
): T {
|
||||
if (requirements.subject && !claims.sub) throw new JwtError("Token subject is required");
|
||||
if (requirements.type && claims.type !== requirements.type)
|
||||
throw new JwtError("Invalid token type");
|
||||
for (const name of requirements.required ?? []) {
|
||||
if (!(name in claims)) throw new JwtError(`Missing required claim: ${name}`);
|
||||
}
|
||||
return claims;
|
||||
}
|
||||
|
||||
export function tokenScopes(claims: JwtClaims): string[] {
|
||||
const value = claims.scopes ?? claims.scope;
|
||||
if (Array.isArray(value))
|
||||
return value.filter((entry): entry is string => typeof entry === "string");
|
||||
if (typeof value === "string") return value.split(/\s+/).filter(Boolean);
|
||||
return [];
|
||||
}
|
||||
|
||||
export function hasScopes(
|
||||
claims: JwtClaims,
|
||||
required: readonly string[],
|
||||
mode: "all" | "any" = "all",
|
||||
): boolean {
|
||||
const scopes = new Set(tokenScopes(claims));
|
||||
return mode === "all"
|
||||
? required.every((scope) => scopes.has(scope))
|
||||
: required.some((scope) => scopes.has(scope));
|
||||
}
|
||||
|
||||
export function requireScopes(
|
||||
required: readonly string[],
|
||||
mode: "all" | "any" = "all",
|
||||
): Middleware {
|
||||
return async (ctx, next) => {
|
||||
const claims = ctx.user && typeof ctx.user === "object" ? (ctx.user as JwtClaims) : {};
|
||||
if (!hasScopes(claims, required, mode)) {
|
||||
return Response.json({ ok: false, error: "Insufficient scope" }, { status: 403 });
|
||||
}
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
export function createAccessToken(
|
||||
subject: string,
|
||||
secret: string,
|
||||
options: Omit<SignOptions, "expiresIn"> & {
|
||||
expiresIn?: number;
|
||||
scopes?: string[];
|
||||
claims?: JwtClaims;
|
||||
} = {},
|
||||
): Promise<string> {
|
||||
const { claims, scopes, expiresIn, ...signOptions } = options;
|
||||
return signJwt(
|
||||
{ ...claims, sub: subject, type: "access", ...(scopes ? { scopes } : {}) },
|
||||
secret,
|
||||
{ ...signOptions, expiresIn: expiresIn ?? 15 * 60 },
|
||||
);
|
||||
}
|
||||
|
||||
export function createRefreshToken(
|
||||
subject: string,
|
||||
secret: string,
|
||||
options: Omit<SignOptions, "expiresIn"> & {
|
||||
expiresIn?: number;
|
||||
family?: string;
|
||||
claims?: JwtClaims;
|
||||
} = {},
|
||||
): Promise<string> {
|
||||
const { claims, family, expiresIn, ...signOptions } = options;
|
||||
return signJwt(
|
||||
{ ...claims, sub: subject, type: "refresh", ...(family ? { family } : {}) },
|
||||
secret,
|
||||
{ ...signOptions, expiresIn: expiresIn ?? 30 * 24 * 60 * 60 },
|
||||
);
|
||||
}
|
||||
|
||||
export async function verifyAccessToken(
|
||||
token: string,
|
||||
secret: string,
|
||||
options: VerifyOptions = {},
|
||||
): Promise<AccessTokenClaims> {
|
||||
return assertJwtClaims(await verifyJwt<AccessTokenClaims>(token, secret, options), {
|
||||
subject: true,
|
||||
type: "access",
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyRefreshToken(
|
||||
token: string,
|
||||
secret: string,
|
||||
options: VerifyOptions = {},
|
||||
): Promise<RefreshTokenClaims> {
|
||||
return assertJwtClaims(await verifyJwt<RefreshTokenClaims>(token, secret, options), {
|
||||
subject: true,
|
||||
type: "refresh",
|
||||
});
|
||||
}
|
||||
|
||||
const COOKIE_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
||||
|
||||
function cookieSource(value: Headers | Request | string | null | undefined): string {
|
||||
if (typeof value === "string" || value == null) return value ?? "";
|
||||
return value instanceof Request
|
||||
? (value.headers.get("cookie") ?? "")
|
||||
: (value.get("cookie") ?? "");
|
||||
}
|
||||
|
||||
export function readJwtCookie(
|
||||
value: Headers | Request | string | null | undefined,
|
||||
name = "__Host-wrn_token",
|
||||
): string | undefined {
|
||||
if (!COOKIE_NAME.test(name)) throw new TypeError("Invalid JWT cookie name.");
|
||||
for (const part of cookieSource(value).split(";")) {
|
||||
const index = part.indexOf("=");
|
||||
if (index < 0) continue;
|
||||
const key = part.slice(0, index).trim();
|
||||
if (key !== name) continue;
|
||||
try {
|
||||
return decodeURIComponent(part.slice(index + 1).trim());
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function jwtCookie(
|
||||
token: string,
|
||||
options: {
|
||||
name?: string;
|
||||
maxAge?: number;
|
||||
secure?: boolean;
|
||||
sameSite?: "Strict" | "Lax" | "None";
|
||||
path?: string;
|
||||
} = {},
|
||||
): string {
|
||||
const name = options.name ?? "__Host-wrn_token";
|
||||
const path = options.path ?? "/";
|
||||
const sameSite = options.sameSite ?? "Lax";
|
||||
const secure = options.secure !== false;
|
||||
if (!COOKIE_NAME.test(name)) throw new TypeError("Invalid JWT cookie name.");
|
||||
if (!path.startsWith("/") || /[;\r\n]/.test(path))
|
||||
throw new TypeError("Invalid JWT cookie path.");
|
||||
if (name.startsWith("__Host-") && (path !== "/" || !secure)) {
|
||||
throw new TypeError("__Host- JWT cookies require Path=/ and Secure.");
|
||||
}
|
||||
if (sameSite === "None" && !secure) {
|
||||
throw new TypeError("SameSite=None JWT cookies require Secure.");
|
||||
}
|
||||
const parts = [
|
||||
`${name}=${encodeURIComponent(token)}`,
|
||||
`Path=${path}`,
|
||||
"HttpOnly",
|
||||
`SameSite=${sameSite}`,
|
||||
];
|
||||
if (secure) parts.push("Secure");
|
||||
if (options.maxAge !== undefined) {
|
||||
if (!Number.isFinite(options.maxAge)) throw new RangeError("JWT cookie maxAge must be finite.");
|
||||
parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAge))}`);
|
||||
}
|
||||
return parts.join("; ");
|
||||
}
|
||||
|
||||
export function clearJwtCookie(
|
||||
options: Omit<Parameters<typeof jwtCookie>[1], "maxAge"> = {},
|
||||
): string {
|
||||
return jwtCookie("", { ...options, maxAge: 0 });
|
||||
}
|
||||
|
||||
export interface JwtTokenPair {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
tokenType: "Bearer";
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
export async function createTokenPair(
|
||||
subject: string,
|
||||
input: {
|
||||
accessSecret: string;
|
||||
refreshSecret?: string;
|
||||
accessExpiresIn?: number;
|
||||
refreshExpiresIn?: number;
|
||||
scopes?: string[];
|
||||
family?: string;
|
||||
accessOptions?: Omit<SignOptions, "expiresIn">;
|
||||
refreshOptions?: Omit<SignOptions, "expiresIn">;
|
||||
},
|
||||
): Promise<JwtTokenPair> {
|
||||
const expiresIn = input.accessExpiresIn ?? 15 * 60;
|
||||
const [accessToken, refreshToken] = await Promise.all([
|
||||
createAccessToken(subject, input.accessSecret, {
|
||||
...input.accessOptions,
|
||||
expiresIn,
|
||||
scopes: input.scopes,
|
||||
}),
|
||||
createRefreshToken(subject, input.refreshSecret ?? input.accessSecret, {
|
||||
...input.refreshOptions,
|
||||
expiresIn: input.refreshExpiresIn ?? 30 * 24 * 60 * 60,
|
||||
family: input.family,
|
||||
}),
|
||||
]);
|
||||
return { accessToken, refreshToken, tokenType: "Bearer", expiresIn };
|
||||
}
|
||||
|
||||
export function jwtResponse(
|
||||
accessToken: string,
|
||||
input: { refreshToken?: string; expiresIn?: number; tokenType?: string; scope?: string[] } = {},
|
||||
): Response {
|
||||
return Response.json(
|
||||
{
|
||||
accessToken,
|
||||
tokenType: input.tokenType ?? "Bearer",
|
||||
expiresIn: input.expiresIn ?? 900,
|
||||
...(input.refreshToken ? { refreshToken: input.refreshToken } : {}),
|
||||
...(input.scope ? { scope: input.scope.join(" ") } : {}),
|
||||
},
|
||||
{ headers: { "cache-control": "no-store", pragma: "no-cache" } },
|
||||
);
|
||||
}
|
||||
@@ -231,3 +231,23 @@ function unauthorized(): Response {
|
||||
}
|
||||
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";
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { decodeJwt, type JwtClaims } from "./index.ts";
|
||||
import { JwtError, type VerifyOptions } from "./index.ts";
|
||||
|
||||
export interface RemoteJwksOptions {
|
||||
fetch?: typeof fetch;
|
||||
cacheTtlMs?: number;
|
||||
maxKeys?: number;
|
||||
maxBytes?: number;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export interface RemoteJwks {
|
||||
resolve(kid: string, alg: string): Promise<CryptoKey>;
|
||||
refresh(): Promise<void>;
|
||||
clear(): void;
|
||||
stats(): { fetches: number; hits: number; keys: number; expiresAt: number };
|
||||
}
|
||||
|
||||
type JwksKey = JsonWebKey & { kid?: string; alg?: string; use?: string };
|
||||
type StoredKey = { jwk: JwksKey; key?: Promise<CryptoKey> };
|
||||
|
||||
function decodeBase64Url(value: string): Uint8Array {
|
||||
const padding = value.length % 4 === 0 ? "" : "=".repeat(4 - (value.length % 4));
|
||||
const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/") + padding);
|
||||
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
export function createRemoteJwks(url: string, options: RemoteJwksOptions = {}): RemoteJwks {
|
||||
const endpoint = new URL(url);
|
||||
if (endpoint.protocol !== "https:") throw new TypeError("JWKS URL must use HTTPS");
|
||||
const fetchImpl = options.fetch ?? fetch;
|
||||
const cacheTtlMs = options.cacheTtlMs ?? 5 * 60_000;
|
||||
const maxKeys = options.maxKeys ?? 32;
|
||||
const maxBytes = options.maxBytes ?? 256 * 1024;
|
||||
if (!Number.isFinite(cacheTtlMs) || cacheTtlMs < 0)
|
||||
throw new RangeError("JWKS cacheTtlMs must be non-negative");
|
||||
if (!Number.isInteger(maxKeys) || maxKeys < 1)
|
||||
throw new RangeError("JWKS maxKeys must be positive");
|
||||
if (!Number.isInteger(maxBytes) || maxBytes < 1)
|
||||
throw new RangeError("JWKS maxBytes must be positive");
|
||||
const now = options.now ?? Date.now;
|
||||
const keys = new Map<string, StoredKey>();
|
||||
let expiresAt = 0;
|
||||
let etag: string | undefined;
|
||||
let refreshing: Promise<void> | undefined;
|
||||
let fetches = 0;
|
||||
let hits = 0;
|
||||
|
||||
const refresh = async (): Promise<void> => {
|
||||
if (refreshing) return refreshing;
|
||||
refreshing = (async () => {
|
||||
fetches++;
|
||||
const response = await fetchImpl(endpoint, {
|
||||
headers: etag
|
||||
? { accept: "application/json", "if-none-match": etag }
|
||||
: { accept: "application/json" },
|
||||
});
|
||||
if (response.status === 304) {
|
||||
expiresAt = now() + cacheTtlMs;
|
||||
return;
|
||||
}
|
||||
if (!response.ok) throw new JwtError(`JWKS fetch failed (${response.status})`);
|
||||
const declared = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(declared) && declared > maxBytes)
|
||||
throw new JwtError("JWKS response is too large");
|
||||
const text = await response.text();
|
||||
if (new TextEncoder().encode(text).byteLength > maxBytes)
|
||||
throw new JwtError("JWKS response is too large");
|
||||
let document: { keys?: JwksKey[] };
|
||||
try {
|
||||
document = JSON.parse(text) as { keys?: JwksKey[] };
|
||||
} catch {
|
||||
throw new JwtError("JWKS response is invalid JSON");
|
||||
}
|
||||
if (!Array.isArray(document.keys) || document.keys.length > maxKeys)
|
||||
throw new JwtError("JWKS response has an invalid key set");
|
||||
const next = new Map<string, StoredKey>();
|
||||
for (const jwk of document.keys) {
|
||||
if (
|
||||
typeof jwk.kid !== "string" ||
|
||||
!jwk.kid ||
|
||||
jwk.kty !== "RSA" ||
|
||||
(jwk.use !== undefined && jwk.use !== "sig") ||
|
||||
(jwk.alg !== undefined && jwk.alg !== "RS256") ||
|
||||
typeof jwk.n !== "string" ||
|
||||
typeof jwk.e !== "string"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (next.has(jwk.kid)) throw new JwtError(`JWKS contains duplicate kid: ${jwk.kid}`);
|
||||
next.set(jwk.kid, { jwk });
|
||||
}
|
||||
keys.clear();
|
||||
for (const [kid, key] of next) keys.set(kid, key);
|
||||
etag = response.headers.get("etag") ?? undefined;
|
||||
expiresAt = now() + cacheTtlMs;
|
||||
})().finally(() => {
|
||||
refreshing = undefined;
|
||||
});
|
||||
return refreshing;
|
||||
};
|
||||
|
||||
return {
|
||||
async resolve(kid, alg) {
|
||||
if (!kid) throw new JwtError("Token has no key id");
|
||||
if (alg !== "RS256") throw new JwtError(`Unsupported token algorithm: ${alg}`);
|
||||
if (now() >= expiresAt) await refresh();
|
||||
let stored = keys.get(kid);
|
||||
if (!stored) {
|
||||
await refresh();
|
||||
stored = keys.get(kid);
|
||||
}
|
||||
if (!stored) throw new JwtError(`Unknown key id: ${kid}`);
|
||||
hits++;
|
||||
stored.key ??= crypto.subtle.importKey(
|
||||
"jwk",
|
||||
stored.jwk,
|
||||
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
||||
false,
|
||||
["verify"],
|
||||
);
|
||||
return stored.key;
|
||||
},
|
||||
refresh,
|
||||
clear() {
|
||||
keys.clear();
|
||||
expiresAt = 0;
|
||||
etag = undefined;
|
||||
},
|
||||
stats: () => ({ fetches, hits, keys: keys.size, expiresAt }),
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyJwtWithJwks<T extends JwtClaims = JwtClaims>(
|
||||
token: string,
|
||||
jwks: RemoteJwks,
|
||||
options: VerifyOptions = {},
|
||||
): Promise<T> {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) throw new JwtError("Malformed token");
|
||||
const decoded = decodeJwt(token);
|
||||
const kid = typeof decoded.header.kid === "string" ? decoded.header.kid : "";
|
||||
const alg = typeof decoded.header.alg === "string" ? decoded.header.alg : "";
|
||||
const key = await jwks.resolve(kid, alg);
|
||||
const valid = await crypto.subtle.verify(
|
||||
"RSASSA-PKCS1-v1_5",
|
||||
key,
|
||||
decodeBase64Url(parts[2]!) as BufferSource,
|
||||
new TextEncoder().encode(`${parts[0]}.${parts[1]}`) as BufferSource,
|
||||
);
|
||||
if (!valid) throw new JwtError("Invalid signature");
|
||||
const claims = decoded.claims as T;
|
||||
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.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");
|
||||
}
|
||||
if (
|
||||
options.maxAge !== undefined &&
|
||||
typeof claims.iat === "number" &&
|
||||
now - claims.iat > options.maxAge + tolerance
|
||||
) {
|
||||
throw new JwtError("Token is too old");
|
||||
}
|
||||
return claims;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
clearJwtCookie,
|
||||
createAccessToken,
|
||||
createRefreshToken,
|
||||
createTokenPair,
|
||||
extractBearerToken,
|
||||
hasScopes,
|
||||
jwtCookie,
|
||||
readJwtCookie,
|
||||
tokenScopes,
|
||||
verifyAccessToken,
|
||||
verifyRefreshToken,
|
||||
} from "../src/index.ts";
|
||||
|
||||
const secret = "a-long-jwt-package-helper-test-secret";
|
||||
|
||||
describe("JWT helper kit", () => {
|
||||
test("creates and verifies typed access and refresh tokens", async () => {
|
||||
const access = await createAccessToken("user-1", secret, {
|
||||
scopes: ["profile:read", "profile:write"],
|
||||
expiresIn: 60,
|
||||
now: 100,
|
||||
});
|
||||
const refresh = await createRefreshToken("user-1", secret, {
|
||||
family: "family-1",
|
||||
expiresIn: 600,
|
||||
now: 100,
|
||||
});
|
||||
const accessClaims = await verifyAccessToken(access, secret, { now: 120 });
|
||||
const refreshClaims = await verifyRefreshToken(refresh, secret, { now: 120 });
|
||||
expect(hasScopes(accessClaims, ["profile:read", "profile:write"])).toBe(true);
|
||||
expect(tokenScopes(accessClaims)).toEqual(["profile:read", "profile:write"]);
|
||||
expect(refreshClaims.family).toBe("family-1");
|
||||
});
|
||||
|
||||
test("creates complete access and refresh pairs", async () => {
|
||||
const pair = await createTokenPair("user-1", {
|
||||
accessSecret: secret,
|
||||
accessExpiresIn: 60,
|
||||
refreshExpiresIn: 600,
|
||||
scopes: ["profile:read"],
|
||||
family: "family-2",
|
||||
accessOptions: { now: 100 },
|
||||
refreshOptions: { now: 100 },
|
||||
});
|
||||
expect((await verifyAccessToken(pair.accessToken, secret, { now: 120 })).type).toBe("access");
|
||||
expect((await verifyRefreshToken(pair.refreshToken, secret, { now: 120 })).family).toBe(
|
||||
"family-2",
|
||||
);
|
||||
});
|
||||
|
||||
test("extracts bearer and cookie tokens from supported sources", () => {
|
||||
expect(extractBearerToken("Bearer abc")).toBe("abc");
|
||||
expect(extractBearerToken(new Headers({ authorization: "bearer xyz" }))).toBe("xyz");
|
||||
expect(readJwtCookie("other=x; __Host-wrn_token=abc%20123")).toBe("abc 123");
|
||||
});
|
||||
|
||||
test("enforces secure cookie invariants", () => {
|
||||
expect(jwtCookie("token")).toContain("__Host-wrn_token=token");
|
||||
expect(clearJwtCookie()).toContain("Max-Age=0");
|
||||
expect(() => jwtCookie("token", { path: "/auth" })).toThrow("__Host-");
|
||||
expect(() => jwtCookie("token", { name: "bad;name" })).toThrow("cookie name");
|
||||
expect(() => jwtCookie("token", { name: "token", sameSite: "None", secure: false })).toThrow(
|
||||
"SameSite=None",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { createRemoteJwks, verifyJwtWithJwks } from "../src/index.ts";
|
||||
|
||||
function base64url(value: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (const byte of value) binary += String.fromCharCode(byte);
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
async function rsaKey(kid: string) {
|
||||
const pair = (await crypto.subtle.generateKey(
|
||||
{
|
||||
name: "RSASSA-PKCS1-v1_5",
|
||||
modulusLength: 2048,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: "SHA-256",
|
||||
},
|
||||
true,
|
||||
["sign", "verify"],
|
||||
)) as CryptoKeyPair;
|
||||
const jwk = await crypto.subtle.exportKey("jwk", pair.publicKey);
|
||||
return { pair, jwk: { ...jwk, kid, alg: "RS256", use: "sig" } };
|
||||
}
|
||||
|
||||
async function sign(privateKey: CryptoKey, kid: string, claims: Record<string, unknown>) {
|
||||
const encoder = new TextEncoder();
|
||||
const header = base64url(encoder.encode(JSON.stringify({ alg: "RS256", typ: "JWT", kid })));
|
||||
const payload = base64url(encoder.encode(JSON.stringify(claims)));
|
||||
const input = `${header}.${payload}`;
|
||||
const signature = await crypto.subtle.sign(
|
||||
"RSASSA-PKCS1-v1_5",
|
||||
privateKey,
|
||||
encoder.encode(input),
|
||||
);
|
||||
return `${input}.${base64url(new Uint8Array(signature))}`;
|
||||
}
|
||||
|
||||
test("remote JWKS verifies RS256 claims and refreshes immediately for key rotation", async () => {
|
||||
const first = await rsaKey("key-1");
|
||||
const second = await rsaKey("key-2");
|
||||
const documents = [{ keys: [first.jwk] }, { keys: [second.jwk] }];
|
||||
let fetches = 0;
|
||||
const jwks = createRemoteJwks("https://issuer.example/jwks", {
|
||||
now: () => 0,
|
||||
cacheTtlMs: 60_000,
|
||||
fetch: (async () =>
|
||||
Response.json(
|
||||
documents[Math.min(fetches++, documents.length - 1)],
|
||||
)) as unknown as typeof fetch,
|
||||
});
|
||||
const tokenOne = await sign(first.pair.privateKey, "key-1", {
|
||||
sub: "user-1",
|
||||
iss: "https://issuer.example",
|
||||
aud: "client-1",
|
||||
iat: 100,
|
||||
exp: 200,
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await verifyJwtWithJwks(tokenOne, jwks, {
|
||||
issuer: "https://issuer.example",
|
||||
audience: "client-1",
|
||||
now: 150,
|
||||
})
|
||||
).sub,
|
||||
).toBe("user-1");
|
||||
expect(fetches).toBe(1);
|
||||
|
||||
const tokenTwo = await sign(second.pair.privateKey, "key-2", {
|
||||
sub: "user-2",
|
||||
iss: "https://issuer.example",
|
||||
aud: "client-1",
|
||||
exp: 200,
|
||||
});
|
||||
expect((await verifyJwtWithJwks(tokenTwo, jwks, { now: 150 })).sub).toBe("user-2");
|
||||
expect(fetches).toBe(2);
|
||||
expect(jwks.stats()).toMatchObject({ fetches: 2, hits: 2, keys: 1 });
|
||||
await expect(verifyJwtWithJwks(tokenOne, jwks, { now: 150 })).rejects.toThrow("Unknown key");
|
||||
});
|
||||
|
||||
test("remote JWKS rejects insecure, oversized, and incompatible key sets", async () => {
|
||||
expect(() => createRemoteJwks("http://issuer.example/jwks")).toThrow("HTTPS");
|
||||
const oversized = createRemoteJwks("https://issuer.example/jwks", {
|
||||
maxBytes: 10,
|
||||
fetch: (async () => Response.json({ keys: [] })) as unknown as typeof fetch,
|
||||
});
|
||||
await expect(oversized.refresh()).rejects.toThrow("too large");
|
||||
|
||||
const incompatible = createRemoteJwks("https://issuer.example/jwks", {
|
||||
fetch: (async () =>
|
||||
Response.json({
|
||||
keys: [{ kid: "ec", kty: "EC", alg: "ES256" }],
|
||||
})) as unknown as typeof fetch,
|
||||
});
|
||||
await incompatible.refresh();
|
||||
await expect(incompatible.resolve("ec", "RS256")).rejects.toThrow("Unknown key");
|
||||
});
|
||||
Reference in New Issue
Block a user