From 6ab9446e01361da43d74f6b0d29a500d876a177c Mon Sep 17 00:00:00 2001 From: Tobias Schneider Date: Sun, 26 Jun 2022 17:02:55 +0200 Subject: [PATCH] optimizations --- README.md | 8 +- index.test.js | 15 ++- src/index.ts | 355 +++++++++++++++++++++++--------------------------- 3 files changed, 178 insertions(+), 200 deletions(-) diff --git a/README.md b/README.md index 7c201ae..cd6816c 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,9 @@ npm i -D @tsndr/cloudflare-worker-jwt ### Basic Example -```javascript +```typescript async () => { - const jwt = require('@tsndr/cloudflare-worker-jwt') + import jwt from '@tsndr/cloudflare-worker-jwt' // Creating a token const token = await jwt.sign({ name: 'John Doe', email: 'john.doe@gmail.com' }, 'secret') @@ -45,9 +45,9 @@ async () => { ### Restrict Timeframe -```javascript +```typescript async () => { - const jwt = require('@tsndr/cloudflare-worker-jwt') + import jwt from '@tsndr/cloudflare-worker-jwt' // Creating a token const token = await jwt.sign({ diff --git a/index.test.js b/index.test.js index 84cfc6e..ca83672 100644 --- a/index.test.js +++ b/index.test.js @@ -3,14 +3,25 @@ Object.defineProperty(global, 'crypto', { value: { subtle } }) -const jwt = require('./index.js').default +const jwt = require('./index.js') const oneDay = (60 * 60 * 24) const now = Date.now() / 1000 +const algorithms = [ + 'ES256', + 'ES384', + 'ES512', + 'HS256', + 'HS384', + 'HS512', + 'RS256', + 'RS384', + 'RS512' +] const secrets = {} // Keypairs -for (const algorithm of Object.keys(jwt.algorithms)) { +for (const algorithm of algorithms) { if (algorithm.startsWith('HS')) secrets[algorithm] = 'secret' else if (algorithm.startsWith('RS')) { diff --git a/src/index.ts b/src/index.ts index 2112820..f2537fc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,12 @@ +if (typeof crypto === 'undefined' || !crypto.subtle) + throw new Error('SubtleCrypto not supported!') + +/** + * @typedef JwtAlgorithm + * @type {'ES256'|'ES384'|'ES512'|'HS256'|'HS384'|'HS512'|'RS256'|'RS384'|'RS512'} + */ +type JwtAlgorithm = 'ES256'|'ES384'|'ES512'|'HS256'|'HS384'|'HS512'|'RS256'|'RS384'|'RS512' + /** * @typedef JwtAlgorithms */ @@ -5,22 +14,6 @@ interface JwtAlgorithms { [key: string]: SubtleCryptoImportKeyAlgorithm } -/** - * @typedef JwtAlgorithm - * @enum {string} - */ -enum JwtAlgorithm { - ES256 = 'ES256', - ES384 = 'ES384', - ES512 = 'ES512', - HS256 = 'HS256', - HS384 = 'HS384', - HS512 = 'HS512', - RS256 = 'RS256', - RS384 = 'RS384', - RS512 = 'RS512' -} - /** * @typedef JwtHeader * @prop {string} [typ] Type @@ -91,7 +84,7 @@ interface JwtSignOptions extends JwtOptions { /** * @typedef JwtVerifyOptions * @extends JwtOptions - * @prop {boolean=false} [throwError] If `true` throw error if checks fail. (default: `false`) + * @prop {boolean} [throwError=false] If `true` throw error if checks fail. (default: `false`) */ interface JwtVerifyOptions extends JwtOptions { /** @@ -112,194 +105,168 @@ interface JwtData { payload: JwtPayload } +function base64UrlParse(s: string): Uint8Array { + // @ts-ignore + return new Uint8Array(Array.prototype.map.call(atob(s.replace(/-/g, '+').replace(/_/g, '/').replace(/\s/g, '')), c => c.charCodeAt(0))) + // return new Uint8Array(Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/').replace(/\s/g, ''))).map(c => c.charCodeAt(0))) +} + +function base64UrlStringify(a: Uint8Array): string { + // @ts-ignore + return btoa(String.fromCharCode.apply(0, a)).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_') + // return btoa(String.fromCharCode.apply(0, Array.from(a))).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_') +} + +const algorithms: JwtAlgorithms = { + ES256: { name: 'ECDSA', namedCurve: 'P-256', hash: { name: 'SHA-256' } }, + ES384: { name: 'ECDSA', namedCurve: 'P-384', hash: { name: 'SHA-384' } }, + ES512: { name: 'ECDSA', namedCurve: 'P-521', hash: { name: 'SHA-512' } }, + 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' } } +} + +function _utf8ToUint8Array(str: string): Uint8Array { + return base64UrlParse(btoa(unescape(encodeURIComponent(str)))) +} + +function _str2ab(str: string): ArrayBuffer { + str = atob(str) + const buf = new ArrayBuffer(str.length); + const bufView = new Uint8Array(buf); + for (let i = 0, strLen = str.length; i < strLen; i++) { + bufView[i] = str.charCodeAt(i); + } + return buf; +} + +function _decodePayload(raw: string): JwtHeader | JwtPayload | null { + switch (raw.length % 4) { + case 0: + break + case 2: + raw += '==' + break + case 3: + raw += '=' + break + default: + throw new Error('Illegal base64url string!') + } + try { + return JSON.parse(decodeURIComponent(escape(atob(raw)))) + } catch { + return null + } +} + /** - * Base64Url + * Signs a payload and returns the token * - * @public - * @class + * @param {JwtPayload} payload The payload object. To use `nbf` (Not Before) and/or `exp` (Expiration Time) add `nbf` and/or `exp` to the payload. + * @param {string} secret A string which is used to sign the payload. + * @param {JwtSignOptions | JwtAlgorithm | string} [options={ algorithm: 'HS256', header: { typ: 'JWT' } }] The options object or the algorithm. + * @throws {Error} If there's a validation issue. + * @returns {Promise} Returns token as a `string`. */ -class Base64Url { - /** - * @param {string} s - * @returns {Uint8Array} - */ - public static parse(s: string): Uint8Array { - // @ts-ignore - return new Uint8Array(Array.prototype.map.call(atob(s.replace(/-/g, '+').replace(/_/g, '/').replace(/\s/g, '')), c => c.charCodeAt(0))) - // return new Uint8Array(Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/').replace(/\s/g, ''))).map(c => c.charCodeAt(0))) - } - - /** - * @param {Uint8Array} a - * @returns {string} - */ - public static stringify(a: Uint8Array): string { - // @ts-ignore - return btoa(String.fromCharCode.apply(0, a)).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_') - // return btoa(String.fromCharCode.apply(0, Array.from(a))).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_') - } +export async function sign(payload: JwtPayload, secret: string, options: JwtSignOptions | JwtAlgorithm = { algorithm: 'HS256', header: { typ: 'JWT' } }): Promise { + if (typeof options === 'string') + options = { algorithm: options, header: { typ: 'JWT' } } + options = { algorithm: 'HS256', header: { typ: 'JWT' }, ...options } + if (payload === null || typeof payload !== 'object') + throw new Error('payload must be an object') + if (typeof secret !== 'string') + throw new Error('secret must be a string') + if (typeof options.algorithm !== 'string') + throw new Error('options.algorithm must be a string') + const algorithm: SubtleCryptoImportKeyAlgorithm = algorithms[options.algorithm] + if (!algorithm) + throw new Error('algorithm not found') + payload.iat = Math.floor(Date.now() / 1000) + const payloadAsJSON = JSON.stringify(payload) + const partialToken = `${base64UrlStringify(_utf8ToUint8Array(JSON.stringify({ ...options.header, alg: options.algorithm })))}.${base64UrlStringify(_utf8ToUint8Array(payloadAsJSON))}` + let keyFormat = 'raw' + let keyData + if (secret.startsWith('-----BEGIN')) { + keyFormat = 'pkcs8' + keyData = _str2ab(secret.replace(/-----BEGIN.*?-----/g, '').replace(/-----END.*?-----/g, '').replace(/\s/g, '')) + } else + keyData = _utf8ToUint8Array(secret) + const key = await crypto.subtle.importKey(keyFormat, keyData, algorithm, false, ['sign']) + const signature = await crypto.subtle.sign(algorithm, key, _utf8ToUint8Array(partialToken)) + return `${partialToken}.${base64UrlStringify(new Uint8Array(signature))}` } /** - * Jwt - * - * @public - * @class + * Verifies the integrity of the token and returns a boolean value. + * + * @param {string} token The token string generated by `jwt.sign()`. + * @param {string} secret The string which was used to sign the payload. + * @param {JWTVerifyOptions | JWTAlgorithm} options The options object or the algorithm. + * @throws {Error | string} Throws an error `string` if the token is invalid or an `Error-Object` if there's a validation issue. + * @returns {Promise} Returns `true` if signature, `nbf` (if set) and `exp` (if set) are valid, otherwise returns `false`. */ -class Jwt { - - protected algorithms: JwtAlgorithms = { - ES256: { name: 'ECDSA', namedCurve: 'P-256', hash: { name: 'SHA-256' } }, - ES384: { name: 'ECDSA', namedCurve: 'P-384', hash: { name: 'SHA-384' } }, - ES512: { name: 'ECDSA', namedCurve: 'P-521', hash: { name: 'SHA-512' } }, - 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' } } +export async function verify(token: string, secret: string, options: JwtVerifyOptions | JwtAlgorithm = { algorithm: 'HS256', throwError: false }): Promise { + if (typeof options === 'string') + options = { algorithm: options, throwError: false } + options = { algorithm: 'HS256', throwError: false, ...options } + if (typeof token !== 'string') + throw new Error('token must be a string') + if (typeof secret !== 'string') + throw new Error('secret must be a string') + if (typeof options.algorithm !== 'string') + throw new Error('options.algorithm must be a string') + const tokenParts = token.split('.') + if (tokenParts.length !== 3) + throw new Error('token must consist of 3 parts') + const algorithm: SubtleCryptoImportKeyAlgorithm = algorithms[options.algorithm] + if (!algorithm) + throw new Error('algorithm not found') + const { payload } = decode(token) + if (!payload) { + if (options.throwError) + throw 'PARSE_ERROR' + return false } - - constructor() { - if (typeof crypto === 'undefined' || !crypto.subtle) - throw new Error('SubtleCrypto not supported!') + if (payload.nbf && payload.nbf > Math.floor(Date.now() / 1000)) { + if (options.throwError) + throw 'NOT_YET_VALID' + return false } - - protected _utf8ToUint8Array(str: string): Uint8Array { - return Base64Url.parse(btoa(unescape(encodeURIComponent(str)))) + if (payload.exp && payload.exp <= Math.floor(Date.now() / 1000)) { + if (options.throwError) + throw 'EXPIRED' + return false } + let keyFormat = 'raw' + let keyData + if (secret.startsWith('-----BEGIN')) { + keyFormat = 'spki' + keyData = _str2ab(secret.replace(/-----BEGIN.*?-----/g, '').replace(/-----END.*?-----/g, '').replace(/\s/g, '')) + } else + keyData = _utf8ToUint8Array(secret) + const key = await crypto.subtle.importKey(keyFormat, keyData, algorithm, false, ['verify']) + return await crypto.subtle.verify(algorithm, key, base64UrlParse(tokenParts[2]), _utf8ToUint8Array(`${tokenParts[0]}.${tokenParts[1]}`)) +} - protected _str2ab(str: string): ArrayBuffer { - str = atob(str) - const buf = new ArrayBuffer(str.length); - const bufView = new Uint8Array(buf); - for (let i = 0, strLen = str.length; i < strLen; i++) { - bufView[i] = str.charCodeAt(i); - } - return buf; - } - - protected _decodePayload(raw: string): JwtHeader | JwtPayload | null { - switch (raw.length % 4) { - case 0: - break - case 2: - raw += '==' - break - case 3: - raw += '=' - break - default: - throw new Error('Illegal base64url string!') - } - try { - return JSON.parse(decodeURIComponent(escape(atob(raw)))) - } catch { - return null - } - } - - /** - * Signs a payload and returns the token - * - * @param {JwtPayload} payload The payload object. To use `nbf` (Not Before) and/or `exp` (Expiration Time) add `nbf` and/or `exp` to the payload. - * @param {string} secret A string which is used to sign the payload. - * @param {JwtSignOptions | JwtAlgorithm | string} [options={ algorithm: 'HS256', header: { typ: 'JWT' } }] The options object or the algorithm. - * @throws {Error} If there's a validation issue. - * @returns {Promise} Returns token as a `string`. - */ - public async sign(payload: JwtPayload, secret: string, options: JwtSignOptions | JwtAlgorithm = { algorithm: JwtAlgorithm.HS256, header: { typ: 'JWT' } }): Promise { - if (typeof options === 'string') - options = { algorithm: options, header: { typ: 'JWT' } } - options = { algorithm: JwtAlgorithm.HS256, header: { typ: 'JWT' }, ...options } - if (payload === null || typeof payload !== 'object') - throw new Error('payload must be an object') - if (typeof secret !== 'string') - throw new Error('secret must be a string') - if (typeof options.algorithm !== 'string') - throw new Error('options.algorithm must be a string') - const algorithm: SubtleCryptoImportKeyAlgorithm = this.algorithms[options.algorithm] - if (!algorithm) - throw new Error('algorithm not found') - payload.iat = Math.floor(Date.now() / 1000) - const payloadAsJSON = JSON.stringify(payload) - const partialToken = `${Base64Url.stringify(this._utf8ToUint8Array(JSON.stringify({ ...options.header, alg: options.algorithm })))}.${Base64Url.stringify(this._utf8ToUint8Array(payloadAsJSON))}` - let keyFormat = 'raw' - let keyData - if (secret.startsWith('-----BEGIN')) { - keyFormat = 'pkcs8' - keyData = this._str2ab(secret.replace(/-----BEGIN.*?-----/g, '').replace(/-----END.*?-----/g, '').replace(/\s/g, '')) - } else - keyData = this._utf8ToUint8Array(secret) - const key = await crypto.subtle.importKey(keyFormat, keyData, algorithm, false, ['sign']) - const signature = await crypto.subtle.sign(algorithm, key, this._utf8ToUint8Array(partialToken)) - return `${partialToken}.${Base64Url.stringify(new Uint8Array(signature))}` - } - - /** - * Verifies the integrity of the token and returns a boolean value. - * - * @param {string} token The token string generated by `jwt.sign()`. - * @param {string} secret The string which was used to sign the payload. - * @param {JWTVerifyOptions | JWTAlgorithm | string} options The options object or the algorithm. - * @throws {Error | string} Throws an error `string` if the token is invalid or an `Error-Object` if there's a validation issue. - * @returns {Promise} Returns `true` if signature, `nbf` (if set) and `exp` (if set) are valid, otherwise returns `false`. - */ - async verify(token: string, secret: string, options: JwtVerifyOptions | JwtAlgorithm = { algorithm: JwtAlgorithm.HS256, throwError: false }): Promise { - if (typeof options === 'string') - options = { algorithm: options, throwError: false } - options = { algorithm: JwtAlgorithm.HS256, throwError: false, ...options } - if (typeof token !== 'string') - throw new Error('token must be a string') - if (typeof secret !== 'string') - throw new Error('secret must be a string') - if (typeof options.algorithm !== 'string') - throw new Error('options.algorithm must be a string') - const tokenParts = token.split('.') - if (tokenParts.length !== 3) - throw new Error('token must consist of 3 parts') - const algorithm: SubtleCryptoImportKeyAlgorithm = this.algorithms[options.algorithm] - if (!algorithm) - throw new Error('algorithm not found') - const { payload } = this.decode(token) - if (!payload) { - if (options.throwError) - throw 'PARSE_ERROR' - return false - } - if (payload.nbf && payload.nbf > Math.floor(Date.now() / 1000)) { - if (options.throwError) - throw 'NOT_YET_VALID' - return false - } - if (payload.exp && payload.exp <= Math.floor(Date.now() / 1000)) { - if (options.throwError) - throw 'EXPIRED' - return false - } - let keyFormat = 'raw' - let keyData - if (secret.startsWith('-----BEGIN')) { - keyFormat = 'spki' - keyData = this._str2ab(secret.replace(/-----BEGIN.*?-----/g, '').replace(/-----END.*?-----/g, '').replace(/\s/g, '')) - } else - keyData = this._utf8ToUint8Array(secret) - const key = await crypto.subtle.importKey(keyFormat, keyData, algorithm, false, ['verify']) - return await crypto.subtle.verify(algorithm, key, Base64Url.parse(tokenParts[2]), this._utf8ToUint8Array(`${tokenParts[0]}.${tokenParts[1]}`)) - } - - /** - * Returns the payload **without** verifying the integrity of the token. Please use `jwt.verify()` first to keep your application secure! - * - * @param {string} token The token string generated by `jwt.sign()`. - * @returns {JwtData} Returns an `object` containing `header` and `payload`. - */ - public decode(token: string): JwtData { - return { - header: this._decodePayload(token.split('.')[0].replace(/-/g, '+').replace(/_/g, '/')) as JwtHeader, - payload: this._decodePayload(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')) as JwtPayload - } +/** + * Returns the payload **without** verifying the integrity of the token. Please use `jwt.verify()` first to keep your application secure! + * + * @param {string} token The token string generated by `jwt.sign()`. + * @returns {JwtData} Returns an `object` containing `header` and `payload`. + */ +export function decode(token: string): JwtData { + return { + header: _decodePayload(token.split('.')[0].replace(/-/g, '+').replace(/_/g, '/')) as JwtHeader, + payload: _decodePayload(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')) as JwtPayload } } -export default new Jwt \ No newline at end of file +export default { + sign, + verify, + decode +} \ No newline at end of file