feat(middleware-jwt): JWT middleware + unit tests
ADD
packages/middleware-jwt/README.md
+# @marblejs/middleware-jwt
+
+A [JWT](http://jwt.io) middleware for [Marble.js](https://github.com/marblejs/marble).
+
+## Installation
+
+```
+$ npm i @marblejs/middleware-jwt
+```
+Requires `@marblejs/core` to be installed.
+
+## Usage
+
+```typescript
+// @TODO
+```
+License: MIT

ADD
packages/middleware-jwt/package.json
+{
+ "name": "@marblejs/middleware-jwt",
+ "version": "1.0.0",
+ "description": "JWT middleware for Marble.js",
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "scripts": {
+ "start": "yarn watch",
+ "watch": "tsc -w",
+ "build": "tsc",
+ "clean": "rimraf dist",
+ "test": "jest --config ../../jest.config.js"
+ },
+ "files": [
+ "dist/"
+ ],
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/marblejs/marble.git"
+ },
+ "engines": {
+ "node": ">= 8.0.0",
+ "yarn": ">= 1.7.0",
+ "npm": ">= 5.0.0"
+ },
+ "author": "marblejs",
+ "license": "MIT",
+ "bugs": {
+ "url": "https://github.com/marblejs/marble/issues"
+ },
+ "homepage": "https://github.com/marblejs/marble#readme",
+ "peerDependencies": {
+ "@marblejs/core": "^1.0.0",
+ "rxjs": "~6.2.2"
+ },
+ "dependencies": {
+ "@types/jsonwebtoken": "^7.2.8",
+ "jsonwebtoken": "^8.3.0"
+ },
+ "devDependencies": {
+ "@marblejs/core": "^1.0.0"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
+}

ADD
packages/middleware-jwt/src/index.ts
+// package public API
+export * from './jwt.middleware';
+export * from './jwt.factory';

ADD
packages/middleware-jwt/src/jwt.factory.ts
+import { Observable } from 'rxjs';
+import * as jwt from 'jsonwebtoken';
+
+export type Payload = string | object | Buffer;
+export type IssuedAt = { iat: number };
+export type GenerateOptions = jwt.SignOptions & { secret: jwt.Secret };
+export type VerifyOptions = jwt.VerifyOptions & { secret: string | Buffer };
+
+export const generateExpirationInHours = (hours = 1) =>
+ Math.floor(Date.now() / 1000) + (60 * 60 * hours);
+
+export const generateToken = ({ secret, ...opts }: GenerateOptions) => (payload: Payload = {}) =>
+ jwt.sign(payload, secret, opts);
+
+export const verifyToken = <T extends object>({ secret, ...opts }: VerifyOptions) => (token: string) =>
+ jwt.verify(token, secret, opts) as T & IssuedAt;
+
+export const verifyToken$ = <T extends object>({ secret, ...opts }: VerifyOptions) => (token: string) =>
+ new Observable<T & IssuedAt>(subscriber => {
+ jwt.verify(token, secret, opts, (error, payload) => {
+ if (error) {
+ return subscriber.error(error);
+ }
+
+ subscriber.next(payload as T & IssuedAt);
+ subscriber.complete();
+ });
+ });

ADD
packages/middleware-jwt/src/jwt.middleware.ts
+import { throwError, of } from 'rxjs';
+import { map, flatMap, catchError, tap } from 'rxjs/operators';
+import { HttpError, HttpStatus, Middleware, HttpRequest } from '@marblejs/core';
+import { parseAuthorizationHeader } from './jwt.util';
+import { verifyToken$, VerifyOptions } from './jwt.factory';
+
+export type AuthorizeMiddlewareConfig = VerifyOptions;
+
+const assignPayloadToRequest = (req: HttpRequest) => (payload: object) => req.user = payload;
+
+export const authorize$ = (config: AuthorizeMiddlewareConfig): Middleware => req$ =>
+ req$.pipe(
+ flatMap(req => of(req).pipe(
+ map(parseAuthorizationHeader),
+ flatMap(verifyToken$(config)),
+ tap(assignPayloadToRequest(req)),
+ flatMap(() => req$),
+ )),
+ catchError(() =>
+ throwError(new HttpError('Unauthorized', HttpStatus.UNAUTHORIZED))
+ )
+ );

ADD
packages/middleware-jwt/src/jwt.util.ts
+import { HttpRequest, internal as i } from '@marblejs/core';
+
+const splitHeader = (header: string) => header.split(' ');
+const getLastElement = (array: string[]) => array[array.length - 1];
+
+export const parseAuthorizationHeader = (req: HttpRequest) =>
+ i.Maybe.of(req.headers.authorization)
+ .map(splitHeader)
+ .map(getLastElement)
+ .valueOr('');

ADD
packages/middleware-jwt/src/spec/index.spec.ts
+import * as API from '../index';
+
+describe('@marblejs/middleware-jwt public API', () => {
+
+ it('should be defined', () => {
+ expect(API.authorize$).toBeDefined();
+ expect(API.generateExpirationInHours).toBeDefined();
+ expect(API.generateToken).toBeDefined();
+ expect(API.verifyToken).toBeDefined();
+ expect(API.verifyToken$).toBeDefined();
+ });
+
+});

ADD
packages/middleware-jwt/src/spec/jwt.factory.spec.ts
+import { JsonWebTokenError } from 'jsonwebtoken';
+import { generateToken, verifyToken, verifyToken$, generateExpirationInHours } from '../jwt.factory';
+
+describe('JWT factory', () => {
+ test('#generateToken generates and verifies valid JWT token', () => {
+ // given
+ const secret = 'test_secret';
+ const payload = { id: 'test_id' };
+
+ // when
+ const token = generateToken({ secret })(payload);
+ const verifiedToken = verifyToken({ secret })(token) as typeof payload & { iat: number };
+
+ // then
+ expect(typeof token).toBe('string');
+ expect(typeof verifiedToken.iat).toBe('number');
+ expect(verifiedToken.id).toEqual(payload.id);
+ });
+
+ test('#generateToken generates and verifies valid JWT token if payload is undefined', () => {
+ // given
+ const secret = 'test_secret';
+
+ // when
+ const token = generateToken({ secret })();
+ const verifiedToken = verifyToken({ secret })(token) as { iat: number };
+
+ // then
+ expect(typeof token).toBe('string');
+ expect(typeof verifiedToken.iat).toBe('number');
+ });
+
+ test('#verifyToken verifies wrong JWT token and throws error', () => {
+ // given
+ const secret = 'test_secret';
+ const payload = { id: 'test_id' };
+ const token = generateToken({ secret: 'wrong_secret' })(payload);
+ const expectedError = new JsonWebTokenError('invalid signature');
+
+ type Payload = typeof payload;
+
+ // when
+ const verifiedToken = () => verifyToken<Payload>({ secret })(token);
+
+ // then
+ expect(verifiedToken).toThrowError(expectedError);
+ });
+
+ test('#verifyToken$ verifies valid JWT token as stream', done => {
+ // given
+ const secret = 'test_secret';
+ const payload = { id: 'test_id' };
+ const token = generateToken({ secret })(payload);
+
+ type Payload = typeof payload;
+
+ // when
+ const verify$ = verifyToken$<Payload>({ secret })(token);
+
+ // then
+ verify$.subscribe(
+ payload => {
+ expect(payload.id).toBeDefined();
+ expect(payload.iat).toBeDefined();
+ done();
+ },
+ () => {
+ fail('Stream should\'t return an error');
+ done();
+ }
+ );
+ });
+
+ test('#verifyToken$ verifies wrong JWT token as stream and throws error', done => {
+ // given
+ const secret = 'test_secret';
+ const payload = { id: 'test_id' };
+
+ type Payload = typeof payload;
+
+ // when
+ const verify$ = verifyToken$<Payload>({ secret })('test_token');
+
+ // then
+ verify$.subscribe(
+ () => {
+ fail('Stream should\'t return next data');
+ done();
+ },
+ error => {
+ expect(error.name).toEqual('JsonWebTokenError');
+ expect(error.message).toEqual('jwt malformed');
+ done();
+ }
+ );
+ });
+
+ test('#verifyToken$ verifies JWT token with wrong secret as stream and throws error', done => {
+ // given
+ const secret = 'test_secret';
+ const payload = { id: 'test_id' };
+ const token = generateToken({ secret })(payload);
+
+ type Payload = typeof payload;
+
+ // when
+ const verify$ = verifyToken$<Payload>({ secret: 'wrong_secret' })(token);
+
+ // then
+ verify$.subscribe(
+ () => {
+ fail('Stream should\'t return next data');
+ done();
+ },
+ error => {
+ expect(error.name).toEqual('JsonWebTokenError');
+ expect(error.message).toEqual('invalid signature');
+ done();
+ }
+ );
+ });
+
+ test('#generateExpirationInHours creates timestamp for JWT expiration date', () => {
+ // given
+ // tslint:disable-next-line:variable-name
+ const time_22h_40m = 1537562461868;
+ // tslint:disable-next-line:variable-name
+ const time_23h_40m = 1537566061;
+ // tslint:disable-next-line:variable-name
+ const time_24_40 = 1537569661;
+
+ // when
+ spyOn(Date, 'now').and.returnValue(time_22h_40m);
+ const timestampWithDefault = generateExpirationInHours();
+ const timestampFor2h = generateExpirationInHours(2);
+
+ // then
+ expect(timestampWithDefault).toEqual(time_23h_40m);
+ expect(timestampFor2h).toEqual(time_24_40);
+ });
+});

ADD
packages/middleware-jwt/src/spec/jwt.middleware.spec.ts
+import { HttpRequest, HttpResponse, HttpError, HttpStatus } from '@marblejs/core';
+import { of, throwError } from 'rxjs';
+import { authorize$ } from '@marblejs/middleware-jwt/src/jwt.middleware';
+
+describe('JWT middleware', () => {
+ let utilModule;
+ let factoryModule;
+
+ beforeEach(() => {
+ jest.unmock('../jwt.util.ts');
+ jest.unmock('../jwt.factory.ts');
+ utilModule = require('../jwt.util.ts');
+ factoryModule = require('../jwt.factory.ts');
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('authorize$ authorizes incoming request and saves JWT payload to "req.user"', done => {
+ // given
+ const mockedSecret = 'test_secret';
+ const mockedToken = 'TEST_TOKEN';
+ const mockedTokenPayload = { id: 'tes_id' };
+ const mockedRequest = { headers: { authorization: `Bearer ${mockedToken}`} } as HttpRequest;
+ const expectedRequest = { ...mockedRequest, user: mockedTokenPayload };
+
+ const req$ = of(mockedRequest);
+ const res = {} as HttpResponse;
+
+ // when
+ utilModule.parseAuthorizationHeader = jest.fn(() => mockedToken);
+ factoryModule.verifyToken$ = jest.fn(() => () => of(mockedTokenPayload));
+
+ const middleware$ = authorize$({ secret: mockedSecret })(req$, res, undefined);
+
+ // then
+ middleware$.subscribe(
+ req => {
+ expect(req).toEqual(expectedRequest);
+ expect(utilModule.parseAuthorizationHeader).toHaveBeenCalledTimes(1);
+ expect(factoryModule.verifyToken$).toHaveBeenCalledTimes(1);
+ done();
+ },
+ () => {
+ fail(`Stream shouldn\'t throw an error`);
+ done();
+ }
+ );
+ });
+
+ test('authorize$ throws error if incoming request is not authorized', done => {
+ // given
+ const mockedSecret = 'test_secret';
+ const mockedToken = 'TEST_TOKEN';
+ const mockedRequest = { headers: { authorization: `Bearer ${mockedToken}`} } as HttpRequest;
+ const expectedError = new HttpError('Unauthorized', HttpStatus.UNAUTHORIZED);
+
+ const req$ = of(mockedRequest);
+ const res = {} as HttpResponse;
+
+ // when
+ utilModule.parseAuthorizationHeader = jest.fn(() => mockedToken);
+ factoryModule.verifyToken$ = jest.fn(() => () => throwError(expectedError));
+
+ const middleware$ = authorize$({ secret: mockedSecret })(req$, res, undefined);
+
+ // then
+ middleware$.subscribe(
+ () => {
+ fail(`Stream should throw an error`);
+ done();
+ },
+ err => {
+ expect(err).toEqual(expectedError);
+ expect(utilModule.parseAuthorizationHeader).toHaveBeenCalledTimes(1);
+ expect(factoryModule.verifyToken$).toHaveBeenCalledTimes(1);
+ done();
+ }
+ );
+ });
+});

ADD
packages/middleware-jwt/src/spec/jwt.util.spec.ts
+import { HttpRequest } from '@marblejs/core';
+import { parseAuthorizationHeader } from '../jwt.util';
+
+describe('JWT util', () => {
+ describe('#parseAuthorizationHeader', () => {
+ test('retrieves JWT token from header', () => {
+ // given
+ const token = 'TEST_TOKEN';
+ const req = { headers: { authorization: `Bearer ${token}` }};
+
+ // when
+ const result = parseAuthorizationHeader(req as HttpRequest);
+
+ // then
+ expect(result).toEqual(token);
+ });
+
+ test('retrieves token string if authorization header is incomplete', () => {
+ // given
+ const token = 'TEST_TOKEN';
+ const req = { headers: { authorization: token } };
+
+ // when
+ const result = parseAuthorizationHeader(req as HttpRequest);
+
+ // then
+ expect(result).toEqual(token);
+ });
+
+ test('returns empty string if JWT token is not found', () => {
+ // given
+ const req = { headers: {} };
+
+ // when
+ const result = parseAuthorizationHeader(req as HttpRequest);
+
+ // then
+ expect(result).toEqual('');
+ });
+ });
+});

ADD
packages/middleware-jwt/tsconfig.json
+{
+ "extends": "../../tsconfig.json",
+
+ "compilerOptions": {
+ "outDir": "./dist/",
+ "declaration": true,
+ "typeRoots": ["../../node_modules/@types"]
+ },
+
+ "include": ["src/**/*.ts"],
+}

MODIFY
yarn.lock
yarn.lock
version "13.4.4"
resolved "https://registry.yarnpkg.com/@types/joi/-/joi-13.4.4.tgz#258bda71b30b6f3f9a11eea3e122fccdab8df053"
+"@types/jsonwebtoken@^7.2.8":
+ version "7.2.8"
+ resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-7.2.8.tgz#8d199dab4ddb5bba3234f8311b804d2027af2b3a"
+ dependencies:
+ "@types/node" "*"
+
"@types/mime@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.0.tgz#5a7306e367c539b9f6543499de8dd519fac37a8b"
@@ -1082,6 +1088,10 @@ bser@^2.0.0:
dependencies:
node-int64 "^0.4.0"
+buffer-equal-constant-time@1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819"
+
buffer-from@^1.0.0, buffer-from@^1.1.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef"
@@ -1766,6 +1776,12 @@ ecc-jsbn@~0.1.1:
jsbn "~0.1.0"
safer-buffer "^2.1.0"
+ecdsa-sig-formatter@1.0.10:
+ version "1.0.10"
+ resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.10.tgz#1c595000f04a8897dfb85000892a0f4c33af86c3"
+ dependencies:
+ safe-buffer "^5.0.1"
+
elegant-spinner@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/elegant-spinner/-/elegant-spinner-1.0.1.tgz#db043521c95d7e303fd8f345bedc3349cfb0729e"
@@ -3384,6 +3400,20 @@ jsonparse@^1.2.0:
version "1.3.1"
resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280"
+jsonwebtoken@^8.3.0:
+ version "8.3.0"
+ resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.3.0.tgz#056c90eee9a65ed6e6c72ddb0a1d325109aaf643"
+ dependencies:
+ jws "^3.1.5"
+ lodash.includes "^4.3.0"
+ lodash.isboolean "^3.0.3"
+ lodash.isinteger "^4.0.4"
+ lodash.isnumber "^3.0.3"
+ lodash.isplainobject "^4.0.6"
+ lodash.isstring "^4.0.1"
+ lodash.once "^4.0.0"
+ ms "^2.1.1"
+
jsprim@^1.2.2:
version "1.4.1"
resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2"
@@ -3393,6 +3423,21 @@ jsprim@^1.2.2:
json-schema "0.2.3"
verror "1.10.0"
+jwa@^1.1.5:
+ version "1.1.6"
+ resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.1.6.tgz#87240e76c9808dbde18783cf2264ef4929ee50e6"
+ dependencies:
+ buffer-equal-constant-time "1.0.1"
+ ecdsa-sig-formatter "1.0.10"
+ safe-buffer "^5.0.1"
+
+jws@^3.1.5:
+ version "3.1.5"
+ resolved "https://registry.yarnpkg.com/jws/-/jws-3.1.5.tgz#80d12d05b293d1e841e7cb8b4e69e561adcf834f"
+ dependencies:
+ jwa "^1.1.5"
+ safe-buffer "^5.0.1"
+
kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0:
version "3.2.2"
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64"
@@ -3593,6 +3638,34 @@ lodash.debounce@^4.0.8:
version "4.0.8"
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
+lodash.includes@^4.3.0:
+ version "4.3.0"
+ resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f"
+
+lodash.isboolean@^3.0.3:
+ version "3.0.3"
+ resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6"
+
+lodash.isinteger@^4.0.4:
+ version "4.0.4"
+ resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343"
+
+lodash.isnumber@^3.0.3:
+ version "3.0.3"
+ resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc"
+
+lodash.isplainobject@^4.0.6:
+ version "4.0.6"
+ resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
+
+lodash.isstring@^4.0.1:
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451"
+
+lodash.once@^4.0.0:
+ version "4.1.1"
+ resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac"
+
lodash.sortby@^4.7.0:
version "4.7.0"
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
@@ -3930,7 +4003,7 @@ ms@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
-ms@^2.0.0:
+ms@^2.0.0, ms@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a"

feat(middleware-jwt): JWT middleware + unit tests