lib/jwt.js


/*
 *  Author: Vlad Seryakov vseryakov@gmail.com
 *  backendjs 2025
 *
 *  Derived from Hono https://github.com/honojs
 */

const crypto = require('node:crypto');
const lib = require(__dirname + '/../lib');

const utf8Encoder = new TextEncoder();

const pemToBinary = (pem) => Buffer.from(pem.replace(/-+(BEGIN|END).*/g, "").replace(/\s/g, ""), "base64");
const encodeJwt = (str) => lib.toBase64url(JSON.stringify(str)).replaceAll("=", "");
const decodeJwt = (str) => JSON.parse(lib.fromBase64url(str));

const isTokenHeader = (obj) => (typeof obj == "object" && obj ?
                                (obj.alg && /^(HS|RS|PS|ES)[0-9]{3}$|^EdDSA$/.test(obj.alg)) &&
                                (!obj.typ || obj.typ === "JWT") :
                                false);

const exportJwk = async (key) => {
    const { kty, alg, e, n, crv, x, y } = await crypto.subtle.exportKey("jwk", key);
    return { kty, alg, e, n, crv, x, y, key_ops: ["verify"] };
}

const algorithms = {
    HS256: { name: "HMAC", hash: { name: "SHA-256" } },
    HS384: { name: "HMAC", hash: { name: "SHA-384" } },
    HS512: { name: "HMAC", hash: { name: "SHA-512" } },
    RS256: { name: "RSASSA-PKCS1-v1_5", hash: { name: "SHA-256" } },
    RS384: { name: "RSASSA-PKCS1-v1_5", hash: { name: "SHA-384" } },
    RS512: { name: "RSASSA-PKCS1-v1_5", hash: { name: "SHA-512" } },
    PS256: { name: "RSA-PSS", hash: { name: "SHA-256" }, saltLength: 32 },
    PS384: { name: "RSA-PSS", hash: { name: "SHA-384" }, saltLength: 48 },
    PS512: { name: "RSA-PSS", hash: { name: "SHA-512" }, saltLength: 64 },
    ES256: { name: "ECDSA", hash: { name: "SHA-256" }, namedCurve: "P-256" },
    ES384: { name: "ECDSA", hash: { name: "SHA-384" }, namedCurve: "P-384" },
    ES512: { name: "ECDSA", hash: { name: "SHA-512" }, namedCurve: "P-521" },
    EdDSA: { name: "Ed25519", namedCurve: "Ed25519" },
}

/**
 * JSON Web Tokens support
 * @module lib/JWT
 */

lib.JWT = {

    algorithms,

    /**
    * @param {object} payload - data to sign including JWT reserved properties below
    * @param {number} [payload.exp] - The token is checked to ensure it has not expired.
    * @param {number} [payload.nbf] - The token is checked to ensure it is not being used before a specified time.
    * @param {number} [payload.iat] - The token is checked to ensure it is not issued in the future.
    * @param {string} [payload.iss] - The token is checked to ensure it has been issued by a trusted issuer.
    * @param {string|string[]} [payload.aud] - The token is checked to ensure it is intended for a specific audience.
    * @param {string|object|CryptoKey} privateKey
    * @param {string} [alg=HS256]
    * @return {Promise} with object result in format { header, token, err }
    * @example
    * const { token } = await lib.JWT.sign(payload, secret, "HS512")
    * const { err } = await lib.JWT.verify(token, secret)
    * @memberof module:lib/JWT
    * @method sign
    * @async
    */
    async sign(payload, privateKey, alg = "HS256")
    {
        const header = typeof privateKey === "object" && privateKey?.alg ?
            { alg: privateKey.alg, typ: "JWT", kid: privateKey.kid } :
            { alg, typ: "JWT" };


        const token = `${encodeJwt(header)}.${encodeJwt(payload)}`;

        const algorithm = algorithms[alg];

        try {
            const cryptoKey = await this.importPrivateKey(privateKey, algorithm);
            const signature = await crypto.subtle.sign(algorithm, cryptoKey, utf8Encoder.encode(token));
            return {
                header,
                token: `${token}.${lib.toBase64url(signature).replaceAll("=", "")}`
            };
        } catch (err) {
            return { err }
        }
    },

    /**
    * @param {string} token
    * @param {string|object|CryptoKey} publicKey
    * @param {object} [options]
    * @param {string | RegExp} [options.iss] - The expected issuer used for verifying the token
    * @param {boolean} [options.nbf] - Verify the `nbf` claim (default: `true`)
    * @param {boolean} [options.exp] - Verify the `exp` claim (default: `true`)
    * @param {boolean} [options.iat] - Verify the `iat` claim (default: `true`)
    * @param {string | string[] | RegExp} [options.aud] - Acceptable audience(s) for the token
    * @return {Promise} withh object result in format { header, payload, err }
    * @memberof module:lib/JWT
    * @method verify
    * @async
    */
    async verify(token, publicKey, options)
    {
        const { alg, iss, nbf = true, exp = true, iat = true, aud } = options || {};

        publicKey = Array.isArray(publicKey) ? publicKey.find((x) => x?.kid === header.kid) : publicKey;
        if (!publicKey) {
            return { err: new Error("invalid public key") };
        }

        const [h, p, s] = lib.split(token, ".");
        const header = decodeJwt(h);
        const payload = decodeJwt(p);
        if (!isTokenHeader(header)) {
            return { err: new Error("JWT.verify: invalid header") };
        }
        const now = Math.round(Date.now() / 1000);
        if (nbf && payload.nbf && payload.nbf > now) {
            return { err: new Error("JWT.verify: not before") };
        }
        if (exp && payload.exp && payload.exp <= now) {
            return { err: new Error("JWT.verify: expired") };
        }
        if (iat && payload.iat && now < payload.iat) {
            return { err: new Error("JWT.verify: issued at") };
        }
        if (iss) {
            if (!payload.iss) {
                return { err: new Error("no issuer") };
            }
            if ((typeof iss === "string" && payload.iss !== iss) ||
                (iss instanceof RegExp && !iss.test(payload.iss))) {
                    return { err: new Error("invalid issuer") };
            }
        }
        if (aud) {
            const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
            if (!audiences.some((x) => (aud instanceof RegExp ? aud.test(x) :
                typeof aud === "string" ? x === aud :
                Array.isArray(aud) && aud.includes(x)))) {
                    return { err: new Error("invalid audience") };
            }
        }

        const algorithm = algorithms[alg || publicKey.alg || header.alg || "HS256"];

        try {
            const cryptoKey = await this.importPublicKey(publicKey, algorithm);
            const rc = await crypto.subtle.verify(algorithm, cryptoKey, lib.fromBase64url(s, true), utf8Encoder.encode(`${h}.${p}`));
            return rc ? { header, payload } : { err: new Error("invalid signature") };

        } catch (err) {
            return { err }
        }
    },


    /**
     * @param {string|object|CryptoKey} key - a private key to import, string can be a secret or PEM
     * @param {string} algorithm
     * @return {Promise} A Promise that fulfills with the imported key as a CryptoKey object.
     * @memberof module:lib/JWT
     * @method importPrivateKey
     * @async
     */
    async importPrivateKey(key, algorithm)
    {
        if (key instanceof CryptoKey) {
            if (key.type !== "private" && key.type !== "secret") {
                throw new Error(`unexpected key type: CryptoKey.type is ${key.type}, expected private or secret`);
            }
            return key;
        }
        if (typeof key === "object") {
            return crypto.subtle.importKey("jwk", key, algorithm, false, ["sign"]);
        }
        if (typeof key === "string" && key.includes("PRIVATE")) {
            return crypto.subtle.importKey("pkcs8", pemToBinary(key), algorithm, false, ["sign"]);
        }
        return crypto.subtle.importKey("raw", utf8Encoder.encode(key), algorithm, false, ["sign"]);
    },

    /**
     * @param {string|object|CryptoKey} key - a public key to import, string can be a secret or PEM
     * @param {string} algorithm
     * @return {Promise} A Promise that fulfills with the imported key as a CryptoKey object.
     * @memberof module:lib/JWT
     * @method importPublicKey
     * @async
     */
    async importPublicKey(key, algorithm)
    {
        if (key instanceof CryptoKey) {
            if (key.type === "public" || key.type === "secret") {
                return key;
            }
            if (key.type == "private") {
                key = await exportJwk(key);
            }
        }
        if (typeof key === "string" && key.includes("PRIVATE")) {
            const privateKey = await crypto.subtle.importKey("pkcs8", pemToBinary(key), algorithm, true, ["sign"]);
            key = await exportJwk(privateKey);
        }
        if (typeof key === "object") {
            return crypto.subtle.importKey("jwk", key, algorithm, false, ["verify"]);
        }
        if (typeof key === "string" && key.includes("PUBLIC")) {
            return crypto.subtle.importKey("spki", pemToBinary(key), algorithm, false, ["verify"]);
        }
        return crypto.subtle.importKey("raw", utf8Encoder.encode(key), algorithm, false, ["verify"]);
    }

}