release: WRNexusJS 0.4.0
This commit is contained in:
@@ -33,6 +33,19 @@ export interface SignOptions {
|
||||
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();
|
||||
@@ -86,8 +99,19 @@ export async function signJwt(
|
||||
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" })));
|
||||
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(
|
||||
@@ -100,7 +124,7 @@ export async function signJwt(
|
||||
export async function verifyJwt<T extends JwtClaims = JwtClaims>(
|
||||
token: string,
|
||||
secret: string,
|
||||
options: { now?: number } = {},
|
||||
options: VerifyOptions = {},
|
||||
): Promise<T> {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) throw new JwtError("Malformed token");
|
||||
@@ -139,8 +163,30 @@ export async function verifyJwt<T extends JwtClaims = JwtClaims>(
|
||||
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");
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -183,3 +229,5 @@ function bearerToken(ctx: Context): string | 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";
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
JwtError,
|
||||
signJwt,
|
||||
verifyJwt,
|
||||
type JwtClaims,
|
||||
type SignOptions,
|
||||
type VerifyOptions,
|
||||
} from "./index.ts";
|
||||
|
||||
export interface JwtKey {
|
||||
id: string;
|
||||
secret: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export interface JwtKeyring {
|
||||
active(): JwtKey;
|
||||
resolve(id: string): JwtKey | undefined;
|
||||
keys(): JwtKey[];
|
||||
}
|
||||
|
||||
const KEY_ID = /^[A-Za-z0-9._-]{1,64}$/;
|
||||
|
||||
function decodePart(value: string): Record<string, unknown> {
|
||||
const padding = value.length % 4 === 0 ? "" : "=".repeat(4 - (value.length % 4));
|
||||
const json = atob(value.replace(/-/g, "+").replace(/_/g, "/") + padding);
|
||||
return JSON.parse(
|
||||
new TextDecoder().decode(Uint8Array.from(json, (character) => character.charCodeAt(0))),
|
||||
) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function decodeJwt(token: string): {
|
||||
header: Record<string, unknown>;
|
||||
claims: JwtClaims;
|
||||
} {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) throw new JwtError("Malformed token");
|
||||
try {
|
||||
return {
|
||||
header: decodePart(parts[0]!),
|
||||
claims: decodePart(parts[1]!) as JwtClaims,
|
||||
};
|
||||
} catch {
|
||||
throw new JwtError("Invalid token encoding");
|
||||
}
|
||||
}
|
||||
|
||||
export function createJwtKeyring(keys: JwtKey[]): JwtKeyring {
|
||||
const values = new Map<string, JwtKey>();
|
||||
let activeCount = 0;
|
||||
for (const key of keys) {
|
||||
if (!KEY_ID.test(key.id)) throw new TypeError(`Invalid JWT key id: ${key.id}`);
|
||||
if (!key.secret.trim()) throw new TypeError(`JWT key '${key.id}' has an empty secret`);
|
||||
if (values.has(key.id)) throw new Error(`WRN-JWT-KEYRING-DUPLICATE: ${key.id}`);
|
||||
if (key.active) activeCount++;
|
||||
values.set(key.id, { ...key });
|
||||
}
|
||||
if (!values.size) throw new Error("WRN-JWT-KEYRING-EMPTY");
|
||||
if (activeCount > 1) throw new Error("WRN-JWT-KEYRING-MULTIPLE-ACTIVE");
|
||||
|
||||
const activeInternal = (): JwtKey =>
|
||||
[...values.values()].find((key) => key.active) ?? [...values.values()].at(-1)!;
|
||||
|
||||
return {
|
||||
active: () => ({ ...activeInternal() }),
|
||||
resolve(id) {
|
||||
const key = values.get(id);
|
||||
return key ? { ...key } : undefined;
|
||||
},
|
||||
keys: () => [...values.values()].map((key) => ({ ...key })),
|
||||
};
|
||||
}
|
||||
|
||||
export async function signWithKeyring(
|
||||
claims: JwtClaims,
|
||||
keyring: JwtKeyring,
|
||||
options: SignOptions = {},
|
||||
): Promise<string> {
|
||||
const key = keyring.active();
|
||||
return signJwt(claims, key.secret, { ...options, keyId: key.id });
|
||||
}
|
||||
|
||||
export async function verifyWithKeyring<T extends JwtClaims = JwtClaims>(
|
||||
token: string,
|
||||
keyring: JwtKeyring,
|
||||
options: VerifyOptions = {},
|
||||
): Promise<T> {
|
||||
const { header } = decodeJwt(token);
|
||||
const kid = typeof header.kid === "string" ? header.kid : undefined;
|
||||
const keys = keyring.keys();
|
||||
const key = kid ? keyring.resolve(kid) : keys.length === 1 ? keys[0] : undefined;
|
||||
if (!key) {
|
||||
throw new JwtError(kid ? `Unknown key id: ${kid}` : "Token has no key id");
|
||||
}
|
||||
return verifyJwt<T>(token, key.secret, options);
|
||||
}
|
||||
Reference in New Issue
Block a user