@trifrost/core
Version:
Blazingly fast, runtime-agnostic server framework for modern edge and node environments
203 lines (202 loc) • 8.11 kB
JavaScript
import { isObject, omit } from '@valkyriestudios/utils/object';
import { isInt } from '@valkyriestudios/utils/number';
import { b64url, b64urlDecode, utf8Encode, utf8Decode, importKey, ALGOS } from '../utils/Crypto';
export class JWTError extends Error {
constructor(message, cause) {
super(message, { cause });
this.name = 'JWTError';
}
}
export class JWTMalformedError extends JWTError {
constructor() {
super('JWT@verify: Malformed token');
this.name = 'JWTMalformedError';
}
}
export class JWTTypeError extends JWTError {
constructor(expected, actual) {
super(`JWT@verify: Invalid 'typ' header. Expected '${expected}', got '${actual}'`);
this.name = 'JWTTypeError';
}
}
export class JWTTimeError extends JWTError {
constructor(msg) {
super(`JWT@verify: ${msg}`);
this.name = 'JWTTimeError';
}
}
export class JWTClaimError extends JWTError {
constructor(type) {
super(`JWT@verify: INVALID_${type}`);
this.name = 'JWTClaimError';
}
}
export class JWTAlgorithmError extends JWTError {
constructor(reason) {
super(`JWT@verify: Algorithm ${reason.toLowerCase()}`);
this.name = 'JWTAlgorithmError';
}
}
export class JWTSignatureError extends JWTError {
constructor(cause) {
super('JWT@verify: INVALID_SIGNATURE', cause);
this.name = 'JWTSignatureError';
}
}
/**
* Decodes a JWT without verifying its signature.
*
* @param {string} token - JWT string to decode
* @returns The decoded header and payload as a combined object.
* @throws JWTError if token is malformed or missing required parts.
* @see https://jwt.io
*/
export function jwtDecode(token) {
if (typeof token !== 'string')
throw new JWTError('JWT@decode: Invalid token');
const parts = token.split('.');
if (parts.length < 2)
throw new JWTError('JWT@decode: Malformed token');
let jwt_data;
try {
jwt_data = JSON.parse(utf8Decode(b64urlDecode(parts[1])));
jwt_data._header = JSON.parse(utf8Decode(b64urlDecode(parts[0])));
}
catch {
/* Noop */
}
if (!jwt_data || typeof jwt_data !== 'object' || !jwt_data?._header?.alg || typeof jwt_data?._header.alg !== 'string')
throw new JWTError('JWT@decode: Missing algorithm and typ');
return jwt_data;
}
/**
* Signs a payload into a JSON Web Token (JWT).
*
* @param {JWTPayload} payload - The payload to include in the token (must be a plain object)
* @param {string|JsonWebKey|CryptoKey} secret - A secret, key, or CryptoKey used to sign the token
* @param {JWTSignOptions} options - Signing options like `issuer`, `audience`, `expiresIn`, etc.
* @returns A Promise resolving to a JWT string.
* @throws Error if the payload is invalid or secret is missing.
* @see https://jwt.io
*/
export async function jwtSign(secret, options = {}) {
if (!secret)
throw new Error('JWT@sign: Secret must be provided');
if (!isObject(options))
throw new Error('JWT@sign: Options must be provided');
/* Current time */
const now = Math.floor(Date.now() / 1000);
/* Create header */
const header = {
typ: typeof options.type === 'string' ? options.type : 'JWT',
alg: options.algorithm && options.algorithm in ALGOS ? options.algorithm : 'HS256',
};
/* Remove reserved claim keys from user payload */
/* @ts-expect-error Should be good, options.payload is raw and omit doesnt recognize the keys */
const body = isObject(options.payload) ? omit(options.payload, ['iss', 'sub', 'aud', 'exp', 'nbf', 'iat', 'jti']) : {}; // eslint-disable-line prettier/prettier
/* Issued At */
if (!isInt(body.iat))
body.iat = now;
/* Issuer (iss) */
if (typeof options.issuer === 'string')
body.iss = options.issuer;
/* Subject (sub) */
if (typeof options.subject === 'string' || Number.isFinite(options.subject))
body.sub = String(options.subject);
/* Audience (aud) */
if (Array.isArray(options.audience))
body.aud = options.audience.length > 1 ? options.audience : options.audience[0];
else if (typeof options.audience === 'string')
body.aud = options.audience;
/* Expires In (exp) */
if (isInt(options.expiresIn)) {
body.exp = now + options.expiresIn;
}
else if (options.expiresIn !== null) {
body.exp = now + 3600; /* Default 1 hour expiry */
}
/* Not Before (nbf) */
if (isInt(options.notBefore))
body.nbf = now + options.notBefore;
/* JWT Id (jti) */
if (typeof options.jwtid === 'string')
body.jti = options.jwtid;
/* Create token */
const token = b64url(utf8Encode(JSON.stringify(header))) + '.' + b64url(utf8Encode(JSON.stringify(body)));
if (header.alg === 'none')
return token + '.';
/* Load up signing key */
const key = await importKey(secret, ALGOS[header.alg], ['sign']);
/* Sign and concat onto token */
const sig = await crypto.subtle.sign(ALGOS[header.alg], key, utf8Encode(token));
return token + '.' + b64url(new Uint8Array(sig));
}
/**
* Verifies a JWT and returns its decoded payload and header.
*
* @param {string} token - JWT string to verify
* @param {string|JsonWebKey|CryptoKey} secret - secret or public key used to verify the signature
* @param {JWTVerifyOptions} options - Verification constraints such as algorithm, audience, issuer, etc.
* @returns The decoded JWT data if valid.
* @throws JWTError subclasses on failure (e.g. JWTMalformedError, JWTTimeError, JWTSignatureError).
* @see https://jwt.io
*/
export async function jwtVerify(token, secret, options = {}) {
const parts = token.split('.');
if (parts.length !== 3)
throw new JWTMalformedError();
if (!isObject(options))
throw new Error('JWT@verify: Options must be provided');
const [raw_header, raw_payload, sig] = parts;
if (!raw_header || !raw_payload)
throw new JWTMalformedError();
const decoded = jwtDecode(token);
const typ = options.type ?? 'JWT';
if (decoded._header.typ !== typ)
throw new JWTTypeError(typ, decoded._header.typ);
const now = Math.floor(Date.now() / 1000);
const leeway = isInt(options.leeway) ? options.leeway : 0;
/* Verify not-before */
if (isInt(decoded.nbf) && decoded.nbf > now + leeway)
throw new JWTTimeError('NOT_YET_VALID');
/* Verify expiration */
if (isInt(decoded.exp) && decoded.exp <= now - leeway)
throw new JWTTimeError('EXPIRED');
/* Verify issuer */
if (options.issuer && decoded.iss !== options.issuer)
throw new JWTClaimError('ISSUER');
/* Verify audience */
if (options.audience) {
const aud = Array.isArray(options.audience) ? options.audience : [options.audience];
const token_aud = Array.isArray(decoded.aud) ? decoded.aud : typeof decoded.aud === 'string' ? [decoded.aud] : [];
if (!aud.some(a => token_aud.includes(a)))
throw new JWTClaimError('AUDIENCE');
}
/* Verify subject */
if (typeof options.subject === 'function' && (!decoded.sub || !options.subject(decoded.sub)))
throw new JWTClaimError('SUBJECT');
/* Verify algorithm */
const alg = decoded._header.alg;
const optalg = options.algorithm || 'HS256';
if (!alg || !(alg in ALGOS))
throw new JWTAlgorithmError('UNSUPPORTED');
if (alg !== optalg)
throw new JWTAlgorithmError('MISMATCH');
/* If alg is none, no need to continue verifying */
if (alg === 'none')
return decoded;
/* Verify signature exists */
if (!sig)
throw new JWTMalformedError();
/* Validate signature */
try {
const key = await importKey(secret, ALGOS[alg], ['verify']);
const sig_valid = await crypto.subtle.verify(ALGOS[alg], key, b64urlDecode(sig), utf8Encode(raw_header + '.' + raw_payload));
if (!sig_valid)
throw new JWTSignatureError();
}
catch (err) {
throw new JWTSignatureError({ cause: err });
}
return decoded;
}