Files
WRNexusJS/packages/jwt/src/helpers.ts
T
Clintchiz 586a6db8ff
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s
release: WRNexusJS 0.8.0
2026-08-02 23:18:51 +05:30

277 lines
7.8 KiB
TypeScript

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" } },
);
}