From 80cbae394211ef90a7a04d4499826ef8264001b8 Mon Sep 17 00:00:00 2001 From: Oleg Kamlowski Date: Sat, 12 Nov 2022 20:47:49 +0100 Subject: [PATCH] initial commit --- .github/workflows/publish.yml | 26 ++++++++++ .github/workflows/test.yml | 24 +++++++++ .gitignore | 10 ++++ .nycrc | 15 ++++++ package.json | 31 ++++++++++++ src/base64.ts | 35 +++++++++++++ src/decode.ts | 55 ++++++++++++++++++++ src/encode.ts | 38 ++++++++++++++ src/enums/Algorithms.ts | 6 +++ src/enums/index.ts | 1 + src/index.ts | 14 ++++++ src/sign.ts | 17 +++++++ src/types/Algorithm.ts | 3 ++ src/types/Header.ts | 6 +++ src/types/Options.ts | 3 ++ src/types/Payload.ts | 9 ++++ src/types/index.ts | 4 ++ src/verify.ts | 11 ++++ test/decode.test.ts | 94 +++++++++++++++++++++++++++++++++++ test/encode.test.ts | 40 +++++++++++++++ test/utils.ts | 16 ++++++ tsconfig.json | 20 ++++++++ tsconfig.prod.json | 8 +++ 23 files changed, 486 insertions(+) create mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 .nycrc create mode 100644 package.json create mode 100644 src/base64.ts create mode 100644 src/decode.ts create mode 100644 src/encode.ts create mode 100644 src/enums/Algorithms.ts create mode 100644 src/enums/index.ts create mode 100644 src/index.ts create mode 100644 src/sign.ts create mode 100644 src/types/Algorithm.ts create mode 100644 src/types/Header.ts create mode 100644 src/types/Options.ts create mode 100644 src/types/Payload.ts create mode 100644 src/types/index.ts create mode 100644 src/verify.ts create mode 100644 test/decode.test.ts create mode 100644 test/encode.test.ts create mode 100644 test/utils.ts create mode 100644 tsconfig.json create mode 100644 tsconfig.prod.json diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..8bc19b9 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,26 @@ +name: publish + +on: + push: + tags: + - 'v*' + +env: + CI: true + +jobs: + publish: + environment: production + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Use Node.js 12.x + uses: actions/setup-node@v1 + with: + node-version: 12.x + registry-url: https://registry.npmjs.org/ + - run: npm i + - run: npm run build + - run: npm publish + env: + NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..aceaa0b --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,24 @@ +name: test and coverage + +on: [ push, pull_request ] +env: + CI: true + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Use Node.js 12.x + uses: actions/setup-node@v1 + with: + node-version: 12.x + - run: npm i + - run: npm run coverage + + - name: publish coverage + uses: coverallsapp/github-action@master + with: + github-token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..546bf73 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ + +/.idea +/.nyc_output +/node_modules +/coverage +/dist +/types + +.DS_STORE +package-lock.json diff --git a/.nycrc b/.nycrc new file mode 100644 index 0000000..ed91c51 --- /dev/null +++ b/.nycrc @@ -0,0 +1,15 @@ +{ + "extension": [ + ".ts", + ".tsx" + ], + "exclude": [ + "test", + "**/*.d.ts" + ], + "reporter": [ + "html", + "lcov", + "text-summary" + ] +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..e95d323 --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "jted", + "version": "0.1.0", + "description": "just JWT en-/decoding", + "main": "./dist/index.js", + "exports": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc --project tsconfig.prod.json", + "test": "mocha -r ts-node/register/transpile-only --full-trace 'test/**/*.test.ts'", + "watch": "mocha -r ts-node/register/transpile-only --reporter dot --watch --watch-files 'src/**/*.ts' 'test/**/*.test.ts'", + "coverage": "nyc npm test" + }, + "devDependencies": { + "@types/mocha": "^10.0.0", + "@types/node": "^18.11.9", + "mocha": "^10.1.0", + "nyc": "^15.1.0", + "ts-node": "^10.9.1", + "typescript": "^4.8.4" + }, + "author": { + "name": "Oleg Kamlowski", + "email": "oleg.kamlowski@thomann.de" + }, + "license": "Apache-2.0", + "keywords": [ + "jwt", + "JSON Web Tokens" + ] +} diff --git a/src/base64.ts b/src/base64.ts new file mode 100644 index 0000000..8b2bd8e --- /dev/null +++ b/src/base64.ts @@ -0,0 +1,35 @@ +/** + * + * @param string + */ +export const decode = (string: string): string => ( + Buffer.from(unescape(string), 'base64').toString() +); + +/** + * + * @param string + */ +export const unescape = (string: string): string => { + string += new Array(5 - string.length % 4).join('='); + + return string.replace(/-/g, '+').replace(/_/g, '/'); +}; + +/** + * + * @param string + */ +export const encode = (string: string): string => ( + escape(Buffer.from(string).toString('base64')) +); + +/** + * + * @param string + */ +export const escape = (string: string): string => ( + string.replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, '') +); diff --git a/src/decode.ts b/src/decode.ts new file mode 100644 index 0000000..3d59baf --- /dev/null +++ b/src/decode.ts @@ -0,0 +1,55 @@ +import verify from './verify'; +import {decode} from './base64'; +import {Header, Payload, Algorithm} from './types'; +import {Algorithms} from './enums'; + +/** + * User: Oleg Kamlowski + * Date: 16.12.2020 + * Time: 00:18 + */ +export default (token: string, key: string, algorithm: Algorithm = null, validate: boolean = true): Payload => { + if (!token) { + throw new Error('Token can\'t be empty'); + } + + const segments = token.split('.'); + if (segments.length !== 3) { + throw new Error('Token doesn\'t consist of three segments'); + } + + let [header, payload, signature] = segments as any; + + header = JSON.parse(decode(header)) as Header; + payload = JSON.parse(decode(payload)) as Payload; + + if (!validate) { + return payload; + } + + if (/BEGIN( RSA)? PUBLIC KEY/.test(key.toString())) { + algorithm = 'sha256'; + } + + algorithm = algorithm || header.alg; + if (!Algorithms.includes(algorithm)) { + throw new Error('Algorithm not supported'); + } + + const signing = segments.slice(0, 2) + .join('.') + ; + if (!verify(signing, key, algorithm, signature)) { + throw new Error('Signature verification failed'); + } + + if (payload.nbf && Date.now() < payload.nbf * 1000) { + throw new Error('Token not yet active'); + } + + if (payload.exp && Date.now() > payload.exp * 1000) { + throw new Error('Token expired'); + } + + return payload; +} diff --git a/src/encode.ts b/src/encode.ts new file mode 100644 index 0000000..d5f4a98 --- /dev/null +++ b/src/encode.ts @@ -0,0 +1,38 @@ +import sign from './sign'; +import {encode} from './base64'; +import {Algorithm, Options, Header, Payload} from './types'; +import {Algorithms} from './enums'; + +/** + * User: Oleg Kamlowski + * Date: 16.12.2020 + * Time: 00:16 + */ +export default (payload: Payload, key: string, algorithm: Algorithm = 'sha256', options?: Options): string => { + if (!key) { + throw new Error('Key can\'t be empty'); + } + + if (!Algorithms.includes(algorithm)) { + throw new Error('Algorithm not supported'); + } + + const header: Header = { + typ: 'JWT', + alg: algorithm, + }; + + if (options && options.header) { + Object.assign(header, options.header); + } + + const segments = [ + encode(JSON.stringify(header)), + encode(JSON.stringify(payload)), + ]; + + return [ + ...segments, + sign(segments.join('.'), key, algorithm), + ].join('.'); +} diff --git a/src/enums/Algorithms.ts b/src/enums/Algorithms.ts new file mode 100644 index 0000000..53db65f --- /dev/null +++ b/src/enums/Algorithms.ts @@ -0,0 +1,6 @@ +export const Algorithms = [ + 'sha256', + 'sha384', + 'sha512', + 'RSA-SHA256', +] as const; diff --git a/src/enums/index.ts b/src/enums/index.ts new file mode 100644 index 0000000..769f74b --- /dev/null +++ b/src/enums/index.ts @@ -0,0 +1 @@ +export * from './Algorithms'; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..277a205 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,14 @@ +import sign from './sign'; +import decode from './decode'; +import encode from './encode'; + +/** + * User: Oleg Kamlowski + * Date: 16.12.2020 + * Time: 17:56 + */ +export { + sign, + decode, + encode, +}; diff --git a/src/sign.ts b/src/sign.ts new file mode 100644 index 0000000..2d6067c --- /dev/null +++ b/src/sign.ts @@ -0,0 +1,17 @@ +import {createHmac} from 'crypto'; +import {escape} from './base64'; +import {Algorithm} from './types'; + +/** + * User: Oleg Kamlowski + * Date: 15.12.2020 + * Time: 23:56 + */ +export default (input: string, key: string, algorithm: Algorithm) => { + const str = createHmac(algorithm, key) + .update(input) + .digest('base64') + ; + + return escape(str); +} diff --git a/src/types/Algorithm.ts b/src/types/Algorithm.ts new file mode 100644 index 0000000..d624ed0 --- /dev/null +++ b/src/types/Algorithm.ts @@ -0,0 +1,3 @@ +import {Algorithms} from "../enums"; + +export type Algorithm = typeof Algorithms[number]; \ No newline at end of file diff --git a/src/types/Header.ts b/src/types/Header.ts new file mode 100644 index 0000000..45d0475 --- /dev/null +++ b/src/types/Header.ts @@ -0,0 +1,6 @@ +import {Algorithm} from "../types"; + +export type Header = { + typ: string, + alg?: Algorithm; +} \ No newline at end of file diff --git a/src/types/Options.ts b/src/types/Options.ts new file mode 100644 index 0000000..279c0c5 --- /dev/null +++ b/src/types/Options.ts @@ -0,0 +1,3 @@ +export type Options = { + header: object +} \ No newline at end of file diff --git a/src/types/Payload.ts b/src/types/Payload.ts new file mode 100644 index 0000000..b483304 --- /dev/null +++ b/src/types/Payload.ts @@ -0,0 +1,9 @@ +export type Payload = { + [key: string]: any, + sub?: string | number, + iss?: string, + aud?: string, + nbf?: number, + exp?: number, + iat?: number, +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..134fcc1 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,4 @@ +export * from './Algorithm'; +export * from './Header'; +export * from './Options'; +export * from './Payload'; diff --git a/src/verify.ts b/src/verify.ts new file mode 100644 index 0000000..6225d93 --- /dev/null +++ b/src/verify.ts @@ -0,0 +1,11 @@ +import sign from './sign'; +import {Algorithm} from './types'; + +/** + * User: Oleg Kamlowski + * Date: 16.12.2020 + * Time: 00:11 + */ +export default (input: string, key: string, algorithm: Algorithm, signature: string) => ( + signature === sign(input, key, algorithm) +); diff --git a/test/decode.test.ts b/test/decode.test.ts new file mode 100644 index 0000000..de456c2 --- /dev/null +++ b/test/decode.test.ts @@ -0,0 +1,94 @@ +import assert from 'assert'; +import {encode, decode} from '../src'; +import {equals} from './utils'; + +describe('jted', () => { + describe('decode', () => { + const key = 'key'; + const payload = {foo: 'bar'}; + const token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJzaGEyNTYifQ.eyJmb28iOiJiYXIifQ.VpqhmNoRgnbjhN7iv36NXmGJv_JENoF-0csx8astfno'; + + it('decode token', () => { + const actual = decode(token, key); + + assert(equals(actual, payload)); + }); + + it('should throw an error when no token is provided', () => { + assert.throws(() => decode(null, null), { + name: 'Error', + message: 'Token can\'t be empty', + }); + }); + + it('should throw an error when the token is not correctly formatted', () => { + assert.throws(() => decode('foo.bar', null), { + name: 'Error', + message: 'Token doesn\'t consist of three segments', + }); + }); + + it('should throw an error when the specified algorithm is not supported', () => { + const token = 'eyJhbGciOiJ5ZWV0IiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.emP08u28tDrfxdyKmL_IyKHhpbxb8ghu8UEOyhdRWdw'; + + assert.throws(() => decode(token, key, null), { + name: 'Error', + message: 'Algorithm not supported', + }); + }); + + it('should throw an error when the signature verification fails', () => { + assert.throws(() => decode(token, 'invalid_key'), { + name: 'Error', + message: 'Signature verification failed', + }); + }); + + it('should throw an error when the token is not yet active (optional nbf claim)', () => { + const nbf = (Date.now() + 1000) / 1000; + const token = encode({foo: 'bar', nbf}, key); + + assert.throws(() => decode(token, key), { + name: 'Error', + message: 'Token not yet active', + }); + }); + + it('should throw an error when the token has expired (optional exp claim)', () => { + const exp = (Date.now() - 1000) / 1000; + const token = encode({foo: 'bar', exp}, key); + + assert.throws(() => decode(token, key), { + name: 'Error', + message: 'Token expired', + }); + }); + + it('should not throw any error when verification is disabled', () => { + const token = encode(payload, key); + + assert.throws(() => decode(token, 'invalid_key1'), { + name: 'Error', + message: 'Signature verification failed', + }); + + assert(equals(decode(token, 'invalid_key2', 'sha256', false), payload)); + }); + + it('should decode token given algorithm via key', () => { + const key = 'BEGIN PUBLIC KEY'; + const token = encode(payload, key); + const actual = decode(token, key); + + assert(equals(actual, payload)); + }); + + it('should decode token given algorithm via header', () => { + const key = 'BEGIN PUBLIC KEY'; + const token = encode(payload, key, 'sha512'); + const actual = decode(token, key, null, false); + + assert(equals(actual, payload)); + }); + }); +}); diff --git a/test/encode.test.ts b/test/encode.test.ts new file mode 100644 index 0000000..d46cf4c --- /dev/null +++ b/test/encode.test.ts @@ -0,0 +1,40 @@ +import {encode} from '../src'; +import assert from 'assert'; + +describe('jted', () => { + describe('encode', () => { + it('encode token', () => { + const token = encode({foo: 'bar'}, 'key'); + + assert(typeof token === 'string'); + assert(token.split('.').length === 3); + }); + + it('should thrown an error if keys is missing', () => { + assert.throws(() => encode({foo: 'bar'}, null), { + name: 'Error', + message: 'Key can\'t be empty', + }); + }); + + it('should thrown an error when algorithm is not supported', () => { + assert.throws(() => encode({foo: 'bar'}, 'some_key', 'FooBar256' as any), { + name: 'Error', + message: 'Algorithm not supported', + }); + }); + + it('should set the optional headers', () => { + const options = { + header: { + foo: 'bar', + }, + }; + const token = encode({foo: 'bar'}, 'key', 'sha256', options); + + assert(typeof token === 'string'); + assert(token.split('.').length === 3); + }); + }); +}); + diff --git a/test/utils.ts b/test/utils.ts new file mode 100644 index 0000000..b995833 --- /dev/null +++ b/test/utils.ts @@ -0,0 +1,16 @@ +/** + * + * @param a + * @param b + */ +export const equals = (a, b) => { + if (Array.isArray(a)) { + a = a.sort(); + } + + if (Array.isArray(b)) { + b = b.sort(); + } + + return JSON.stringify(a) === JSON.stringify(b); +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1f76757 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "module": "CommonJS", + "resolveJsonModule": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "target": "ES6", + "moduleResolution": "Node", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./src", + "declaration": true + }, + "exclude": [ + "test/**" + ], + "include": [ + "src/**/*" + ] +} diff --git a/tsconfig.prod.json b/tsconfig.prod.json new file mode 100644 index 0000000..99edaf6 --- /dev/null +++ b/tsconfig.prod.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "sourceMap": false, + "declarationMap": false, + "removeComments": true + } +} \ No newline at end of file