release: WRNexusJS 0.8.0
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
2026-08-02 23:18:51 +05:30
parent 87507edf59
commit 586a6db8ff
625 changed files with 243608 additions and 11210 deletions
+276
View File
@@ -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" } },
);
}
+20
View File
@@ -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";
+179
View File
@@ -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;
}