nhb-toolbox
Version:
A versatile collection of smart, efficient, and reusable utility functions, classes and types for everyday development needs.
166 lines (165 loc) • 6.51 kB
JavaScript
import { _secToDate, _toSeconds } from '../date/helpers.js';
import { parseMSec } from '../date/parse.js';
import { isNotEmptyObject } from '../guards/non-primitives.js';
import { isNonEmptyString } from '../guards/primitives.js';
import { stableStringify, stripJsonEdgeGarbage } from '../utils/index.js';
import { _constantTimeEquals } from './helpers.js';
import { base64ToBytes, bytesToBase64, bytesToUtf8, hmacSha256, utf8ToBytes } from './utils.js';
export class Signet {
#secretBytes;
constructor(secret) {
if (!isNonEmptyString(secret)) {
throw new Error('Secret must be a non-empty string!');
}
this.#secretBytes = utf8ToBytes(secret);
}
#decode(token) {
if (!isNonEmptyString(token)) {
throw new Error('Token must be a non-empty string!');
}
const parts = token.split('.');
if (parts.length !== 3) {
throw new Error('Token is tampered or malformed!');
}
const [hdr, pld, signature] = parts;
const headerBytes = base64ToBytes(hdr);
const payloadBytes = base64ToBytes(pld);
const headerStr = stripJsonEdgeGarbage(bytesToUtf8(headerBytes));
const payloadStr = stripJsonEdgeGarbage(bytesToUtf8(payloadBytes));
let header;
try {
header = JSON.parse(headerStr);
}
catch {
throw new Error('Cannot parse header!');
}
let payload;
try {
const { iat, iatDate, exp, expDate, nbf, nbfDate, aud, sub, iss, ...rest } = JSON.parse(payloadStr);
payload = {
iat,
iatDate: iatDate ? new Date(iatDate) : _secToDate(iat),
...(exp && { exp }),
...(exp && { expDate: expDate ? new Date(expDate) : _secToDate(exp) }),
...(nbf && { nbf }),
...(nbf && { nbfDate: nbfDate ? new Date(nbfDate) : _secToDate(nbf) }),
...(aud && { aud }),
...(sub && { sub }),
...(iss && { iss }),
...rest,
};
}
catch {
throw new Error('Cannot parse payload!');
}
return {
header,
payload,
signature,
signingInput: `${hdr}.${pld}`,
};
}
sign(payload, options) {
if (!isNotEmptyObject(payload))
throw new Error('Payload must be a valid object!');
const { expiresIn, notBefore, audience, issuer, subject } = options || {};
const iat = _toSeconds(Date.now());
const $payload = {
iat,
iatDate: _secToDate(iat),
...(expiresIn && { exp: iat + _toSeconds(parseMSec(expiresIn)) }),
...(expiresIn && { expDate: _secToDate(iat + _toSeconds(parseMSec(expiresIn))) }),
...(notBefore && { nbf: iat + _toSeconds(parseMSec(notBefore)) }),
...(notBefore && { nbfDate: _secToDate(iat + _toSeconds(parseMSec(notBefore))) }),
...(audience && { aud: audience }),
...(subject && { sub: subject }),
...(issuer && { iss: issuer }),
...payload,
};
const header = { alg: 'HS256', typ: 'SIGNET+JWT' };
const headerJson = stableStringify(header);
const payloadJson = stableStringify($payload);
const headerB = utf8ToBytes(headerJson);
const payloadB = utf8ToBytes(payloadJson);
const signingInput = `${bytesToBase64(headerB)}.${bytesToBase64(payloadB)}`;
const mac = hmacSha256(this.#secretBytes, utf8ToBytes(signingInput));
const signature = bytesToBase64(mac);
return `${signingInput}.${signature}`;
}
decode(token) {
return this.#decode(token);
}
hasExpired(token) {
const { exp } = this.#decode(token).payload;
return exp ? _toSeconds(Date.now()) > exp : false;
}
isTooEarly(token) {
const { nbf } = this.#decode(token).payload;
return nbf ? _toSeconds(Date.now()) < nbf : false;
}
isInvalidIssuer(token, expected) {
if (!expected)
return false;
const { iss } = this.#decode(token).payload;
return iss ? iss !== expected : false;
}
isInvalidAudience(token, expected) {
if (!expected)
return false;
const { aud } = this.#decode(token).payload;
if (!aud)
return false;
const payloadAud = Array.isArray(aud) ? aud : [aud];
const expectedAud = Array.isArray(expected) ? expected : [expected];
return !payloadAud.some((tokenAud) => expectedAud.includes(tokenAud));
}
isInvalidSubject(token, expected) {
if (!expected)
return false;
const { sub } = this.#decode(token).payload;
return sub ? sub !== expected : false;
}
verify(token, options) {
try {
const { signature, signingInput, payload } = this.#decode(token);
const { audience, issuer, subject } = options || {};
const expectedMac = hmacSha256(this.#secretBytes, utf8ToBytes(signingInput));
const expectedSig = bytesToBase64(expectedMac);
if (!_constantTimeEquals(signature, expectedSig)) {
throw new Error('Invalid or tampered signature!');
}
if (this.hasExpired(token)) {
throw new Error('Token has expired!');
}
if (this.isTooEarly(token)) {
throw new Error('Token is not active yet!');
}
if (this.isInvalidIssuer(token, issuer)) {
throw new Error('Invalid token issuer!');
}
if (this.isInvalidAudience(token, audience)) {
throw new Error('Invalid token audience(s)!');
}
if (this.isInvalidSubject(token, subject)) {
throw new Error('Invalid token subject!');
}
return { isValid: true, payload };
}
catch (e) {
return {
isValid: false,
error: e instanceof Error ? e.message : String(e),
};
}
}
verifyOrThrow(token, options) {
const res = this.verify(token, options);
if (!res.isValid) {
throw new Error(res.error || 'Invalid, malformed or expired token!');
}
return res;
}
decodePayload(token) {
return this.#decode(token).payload;
}
}