UNPKG

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
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; } }