diff --git a/.eslintrc.json b/.eslintrc.json index b91696c..3b7e75a 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -5,10 +5,15 @@ "amd": true, "jest": true }, - "extends": "eslint:recommended", - "parserOptions": { - "ecmaVersion": 2018 - }, + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:@typescript-eslint/recommended" + ], + "parser": "@typescript-eslint/parser", + "plugins": [ + "@typescript-eslint" + ], "rules": { "indent": ["error", 2], "linebreak-style": ["error", "unix"], diff --git a/.gitignore b/.gitignore index 9b5e8ea..5270034 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ node_modules package-lock.json output-template.yml *.tgz -coverage/ \ No newline at end of file +coverage/ +dist/ +.DS_Store diff --git a/__tests__/index.test.js b/__tests__/index.test.ts similarity index 97% rename from __tests__/index.test.js rename to __tests__/index.test.ts index a626044..91ef941 100644 --- a/__tests__/index.test.js +++ b/__tests__/index.test.ts @@ -1,10 +1,11 @@ -const axios = require('axios'); +import axios from 'axios'; jest.mock('axios'); -const { Authenticator } = require('../index'); +import { Authenticator } from '../src/'; const DATE = new Date('2017'); +// @ts-ignore global.Date = class extends Date { constructor() { super(); @@ -28,7 +29,7 @@ describe('private functions', () => { }); test('should fetch token', () => { - axios.request.mockResolvedValue({ data: tokenData }); + axios.request = jest.fn().mockResolvedValue({ data: tokenData }); return authenticator._fetchTokensFromCode('htt://redirect', 'AUTH_CODE') .then(res => { @@ -37,7 +38,7 @@ describe('private functions', () => { }); test('should throw if unable to fetch token', () => { - axios.request.mockRejectedValue(new Error('Unexpected error')); + axios.request = jest.fn().mockRejectedValue(new Error('Unexpected error')); return expect(() => authenticator._fetchTokensFromCode('htt://redirect', 'AUTH_CODE')).rejects.toThrow(); }); @@ -152,7 +153,9 @@ describe('createAuthenticator', () => { }); test('should fail when creating authenticator without params', () => { - expect(() => new Authenticator()).toThrow('Expected params'); + // @ts-ignore + // ts-ignore is used here to override typescript's type check in the constructor + // this test is still useful when the library is imported to a js file expect(() => new Authenticator()).toThrow('Expected params'); }); diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..8cbf894 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,5 @@ +/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', +}; \ No newline at end of file diff --git a/package.json b/package.json index b68e538..88b2b1c 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,13 @@ "description": "Cognito authentication made easy to protect your website with CloudFront and Lambda@Edge.", "author": "AWS Builder Labs ", "license": "Apache-2.0", - "main": "index.js", + "main": "dist/index.js", + "types": "dist/index.d.ts", "files": [ - "index.js" + "/dist" ], "scripts": { + "build": "tsc", "test": "jest --coverage" }, "dependencies": { @@ -21,8 +23,14 @@ "pino": "^6.10.0" }, "devDependencies": { - "eslint": "^7.17.0", - "jest": "^26.6.3" + "@types/aws-lambda": "^8.10.89", + "@types/jest": "^27.4.0", + "@typescript-eslint/eslint-plugin": "^5.9.1", + "@typescript-eslint/parser": "^5.9.1", + "eslint": "^7.32.0", + "jest": "^27.4.7", + "ts-jest": "^27.1.2", + "typescript": "^4.5.4" }, "engines": { "node": ">=10.0.0" diff --git a/index.js b/src/index.ts similarity index 86% rename from index.js rename to src/index.ts index 1e93f92..2bfdd18 100644 --- a/index.js +++ b/src/index.ts @@ -1,10 +1,33 @@ -const axios = require('axios'); -const querystring = require('querystring'); -const pino = require('pino'); -const awsJwtVerify = require('aws-jwt-verify'); +import axios from 'axios'; +import { parse, stringify } from 'querystring'; +import pino from 'pino'; +import { CognitoJwtVerifier } from 'aws-jwt-verify'; +import { CloudFrontRequestEvent } from 'aws-lambda'; -class Authenticator { - constructor(params) { +interface AuthenticatorParams { + region: string; + userPoolId: string; + userPoolAppId: string; + userPoolAppSecret?: string; + userPoolDomain: string; + cookieExpirationDays?: number; + disableCookieDomain?: boolean; + logLevel?: 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'silent'; +} + +export class Authenticator { + _region: string; + _userPoolId: string; + _userPoolAppId: string; + _userPoolAppSecret: string; + _userPoolDomain: string; + _cookieExpirationDays: number; + _disableCookieDomain: boolean; + _cookieBase: string; + _logger; + _jwtVerifier; + + constructor(params: AuthenticatorParams) { this._verifyParams(params); this._region = params.region; this._userPoolId = params.userPoolId; @@ -18,7 +41,7 @@ class Authenticator { level: params.logLevel || 'silent', // Default to silent base: null, //Remove pid, hostname and name logging as not usefull for Lambda }); - this._jwtVerifier = awsJwtVerify.CognitoJwtVerifier.create({ + this._jwtVerifier = CognitoJwtVerifier.create({ userPoolId: params.userPoolId, clientId: params.userPoolAppId, tokenUse: 'id', @@ -57,18 +80,18 @@ class Authenticator { const authorization = this._userPoolAppSecret && Buffer.from(`${this._userPoolAppId}:${this._userPoolAppSecret}`).toString('base64'); const request = { url: `https://${this._userPoolDomain}/oauth2/token`, - method: 'post', + method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(authorization && {'Authorization': `Basic ${authorization}`}), }, - data: querystring.stringify({ + data: stringify({ client_id: this._userPoolAppId, code: code, grant_type: 'authorization_code', redirect_uri: redirectURI, }), - }; + } as const; this._logger.debug({ msg: 'Fetching tokens from grant code...', request, code }); return axios.request(request) .then(resp => { @@ -93,8 +116,8 @@ class Authenticator { const username = decoded['cognito:username']; const usernameBase = `${this._cookieBase}.${username}`; const directives = (!this._disableCookieDomain) ? - `Domain=${domain}; Expires=${new Date(new Date() * 1 + this._cookieExpirationDays * 864e+5)}; Secure` : - `Expires=${new Date(new Date() * 1 + this._cookieExpirationDays * 864e+5)}; Secure`; + `Domain=${domain}; Expires=${new Date(Date.now() + this._cookieExpirationDays * 864e+5)}; Secure` : + `Expires=${new Date(Date.now() + this._cookieExpirationDays * 864e+5)}; Secure`; const response = { status: '302' , headers: { @@ -170,11 +193,11 @@ class Authenticator { * @param {Object} event Lambda@Edge event. * @return {Promise} CloudFront response. */ - async handle(event) { + async handle(event: CloudFrontRequestEvent) { this._logger.debug({ msg: 'Handling Lambda@Edge event', event }); const { request } = event.Records[0].cf; - const requestParams = querystring.parse(request.querystring); + const requestParams = parse(request.querystring); const cfDomain = request.headers.host[0].value; const redirectURI = `https://${cfDomain}`; @@ -217,5 +240,3 @@ class Authenticator { } } } - -module.exports.Authenticator = Authenticator; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..67a3fd0 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "esnext", + "declaration": true, + "outDir": "./dist" + }, + "include": [ + "src/**/*" + ] +} \ No newline at end of file