Skip to content

Commit

Permalink
feat(S2S): implement S2S authentication as Express middleware (#17)
Browse files Browse the repository at this point in the history
* feat(auth): implement S2S authentication as Express middleware

Initial implementation of S2S authentication mechanism as Express middleware that is an event
emitter.

* feat(s2s): move to node-otp for OTP generation

* feat(auth): implement S2S authentication as Express middleware

Initial implementation of S2S authentication mechanism as Express middleware that is an event
emitter.

* feat(s2s): move to node-otp for OTP generation
  • Loading branch information
Daniel-Lam authored May 29, 2020
1 parent 59bf1d7 commit 08f5c14
Show file tree
Hide file tree
Showing 14 changed files with 294 additions and 3 deletions.
3 changes: 3 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,8 @@ module.exports = {
// e.g. "@typescript-eslint/explicit-function-return-type": "off",
'no-extra-semi': "error",

"prettier/prettier": ["error", {
"endOfLine":"auto"
}],
}
};
53 changes: 53 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Jest single run all tests",
"program": "${workspaceFolder}/node_modules/jest/bin/jest.js",
"args": [
"-c",
"./jestconfig.json",
"--verbose",
"--runInBand",
"--no-cache",
"--colors",
],
"cwd": "${workspaceFolder}",
"console": "internalConsole",
"internalConsoleOptions": "openOnSessionStart",
"disableOptimisticBPs": true,
"skipFiles": [
"${workspaceFolder}/node_modules/**/*.js",
"<node_internals>/**/*.js"
],
},
{
"type": "node",
"request": "launch",
"name": "Jest single run opened file",
"program": "${workspaceFolder}/node_modules/jest/bin/jest.js",
"args": [
"-c",
"./jestconfig.json",
"--verbose",
"--runInBand",
"--no-cache",
"--colors",
"${fileBasenameNoExtension}"
],
"cwd": "${workspaceFolder}",
"console": "internalConsole",
"internalConsoleOptions": "openOnSessionStart",
"disableOptimisticBPs": true,
"skipFiles": [
"${workspaceFolder}/node_modules/**/*.js",
"<node_internals>/**/*.js"
],
}
]
}
1 change: 1 addition & 0 deletions customTypes/otp/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
declare module 'otp'
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
"test": "jest",
"test:coverage": "jest --coverage",
"build": "tsc",
"lint": "eslint '*/**/*.{js,ts}' --quiet --fix",
"lint": "eslint \"*/**/*.{js,ts}\" --quiet --fix",
"commit": "git cz",
"ci:lint": "eslint '*/**/*.{js,ts}' --quiet",
"ci:lint": "eslint \"*/**/*.{js,ts}\" --quiet",
"ci": "yarn && yarn ci:lint && yarn build",
"watch": "tsc --watch"
},
Expand Down Expand Up @@ -83,6 +83,7 @@
"express": "^4.17.1",
"express-session": "^1.17.0",
"jwt-decode": "^2.2.0",
"node-otp": "^1.2.2",
"openid-client": "^3.10.0",
"passport": "^0.4.1",
"passport-oauth2": "^1.5.0",
Expand Down
6 changes: 6 additions & 0 deletions src/auth/s2s/decodedJwt.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* Simple type representing a decoded JSON Web Token (JWT).
*/
export interface DecodedJWT {
exp: number
}
46 changes: 46 additions & 0 deletions src/auth/s2s/s2s.class.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// import axios from 'axios'
// import { AxiosInstance } from 'axios'
// import { AxiosResponse } from 'axios'
// import chai from 'chai'
// import { expect } from 'chai'
// // eslint-disable-next-line @typescript-eslint/camelcase
// import jwt_decode from 'jwt-decode'
// import sinon from 'sinon'
// import sinonChai from 'sinon-chai'
// import { mockReq, mockRes } from 'sinon-express-mock'
import { S2SConfig } from './s2sConfig.interface'
import { S2SAuth } from './s2s.class'
// import * as serviceAuth from './serviceAuth'

// chai.use(sinonChai)

describe('S2SAuth', () => {
let s2sAuth: S2SAuth
const s2sConfig = {
microservice: 'rpx-xui',
s2sEndpointUrl: 'http://s2s.local',
s2sSecret: 'topSecret',
} as S2SConfig
// const token = 'abc123'

beforeEach(() => {
s2sAuth = new S2SAuth()
s2sAuth.configure(s2sConfig)
// sinon.stub(serviceAuth, 'postS2SLease').resolves(token)
// eslint-disable-next-line @typescript-eslint/camelcase
// sinon.stub(jwt_decode.prototype).returns({ exp: Math.floor(Date.now() + 10000 / 1000) })
})

it('should be true that S2SAuth is defined', () => {
expect(S2SAuth).toBeDefined()
})

// xit('should return true that the cache is valid', () => {
// const req = mockReq()
// const res = mockRes()
// // eslint-disable-next-line @typescript-eslint/no-empty-function
// const next = (): void => {}

// s2sAuth.s2sHandler(req, res, next)
// })
})
95 changes: 95 additions & 0 deletions src/auth/s2s/s2s.class.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import events from 'events'
import { NextFunction, Request, RequestHandler, Response, Router } from 'express'
// eslint-disable-next-line @typescript-eslint/camelcase
import jwt_decode from 'jwt-decode'
import { DecodedJWT } from './decodedJwt.interface'
import { S2S } from './s2s.constants'
import { S2SConfig } from './s2sConfig.interface'
import { S2SToken } from './s2sToken.interface'
import { postS2SLease } from './serviceAuth'

// TODO: To be replaced with a proper logging library
const logger = console

// Cache of S2S tokens, indexed by microservice name
const cache: { [key: string]: S2SToken } = {}

export class S2SAuth extends events.EventEmitter {
protected readonly router = Router({ mergeParams: true })

protected s2sConfig!: S2SConfig

constructor() {
super()
}

public configure = (s2sConfig: S2SConfig): RequestHandler => {
this.s2sConfig = s2sConfig
this.router.use(this.s2sHandler)

return this.router
}

public s2sHandler = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const token = await this.serviceTokenGenerator()

if (token) {
logger.info('Adding S2S token to request headers')
req.headers.ServiceAuthorization = `Bearer ${token}`

// If there are no listeners for a success event from this emitter, just return this middleware using
// next(), else emit a success event with the S2S token
if (!this.listenerCount(S2S.EVENT.AUTHENTICATE_SUCCESS)) {
logger.log(`S2SAuth: no listener count: ${S2S.EVENT.AUTHENTICATE_SUCCESS}`)
return next()
} else {
this.emit(S2S.EVENT.AUTHENTICATE_SUCCESS, token, req, res, next)
return
}
}
} catch (error) {
logger.log('S2SAuth error:', error)
next(error)
}
}

public validateCache = (): boolean => {
logger.info('Validating S2S cache')
const currentTime = Math.floor(Date.now() / 1000)
if (!cache[this.s2sConfig.microservice]) {
return false
}
return currentTime < cache[this.s2sConfig.microservice].expiresAt
}

public getToken = (): S2SToken => {
return cache[this.s2sConfig.microservice]
}

public generateToken = async (): Promise<string> => {
logger.info('Getting new S2S token')
const token = await postS2SLease(this.s2sConfig)

const tokenData: DecodedJWT = jwt_decode(token)

cache[this.s2sConfig.microservice] = {
expiresAt: tokenData.exp,
token,
} as S2SToken

return token
}

public serviceTokenGenerator = async (): Promise<string> => {
if (this.validateCache()) {
logger.info('Getting cached S2S token')
const tokenData = this.getToken()
return tokenData.token
} else {
return await this.generateToken()
}
}
}

export default new S2SAuth()
6 changes: 6 additions & 0 deletions src/auth/s2s/s2s.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const S2S = {
EVENT: {
AUTHENTICATE_SUCCESS: 's2s.authenticate.success',
AUTHENTICATE_FAILURE: 's2s.authenticate.failure',
},
}
5 changes: 5 additions & 0 deletions src/auth/s2s/s2sConfig.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export interface S2SConfig {
microservice: string
s2sEndpointUrl: string
s2sSecret: string
}
8 changes: 8 additions & 0 deletions src/auth/s2s/s2sToken.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* Simple wrapper type representing an S2S token, containing "expiresAt" (the exp value from the decoded token) and
* wrapping the raw token itself.
*/
export interface S2SToken {
expiresAt: number
token: string
}
43 changes: 43 additions & 0 deletions src/auth/s2s/serviceAuth.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// import { AxiosResponse } from 'axios'
// import chai from 'chai'
// import { expect } from 'chai'
// import * as nodeOtp from 'node-otp'
// import sinon from 'sinon'
// import sinonChai from 'sinon-chai'
// import { http } from '../../http/http'
// import { S2SConfig } from './s2sConfig.interface'
// import * as serviceAuth from './serviceAuth'

// chai.use(sinonChai)

// describe('serviceAuth', () => {
// const s2sConfig = {
// microservice: 'rpx-xui',
// s2sEndpointUrl: 'http://s2s.local',
// s2sSecret: 'topSecret',
// } as S2SConfig
// const oneTimePassword = 'password'

// afterEach(() => {
// sinon.restore()
// })

// it('should make a http.post call to the S2S endpoint URL to obtain a token', async () => {
// // Stub the http.post call to return a mock response
// sinon.stub(http, 'post').resolves({ data: 'okay' } as AxiosResponse)

// // Stub the node-otp totp call to return a dummy password
// sinon.stub(nodeOtp, 'totp').returns(oneTimePassword)

// const response = await serviceAuth.postS2SLease(s2sConfig)
// expect(http.post).to.be.calledWith(s2sConfig.s2sEndpointUrl, {
// microservice: s2sConfig.microservice,
// oneTimePassword,
// })
// expect(response).to.equal('okay')
// })
// })

it('should serviceAuth', () => {
expect(true).toBeTruthy()
})
19 changes: 19 additions & 0 deletions src/auth/s2s/serviceAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { totp } from 'node-otp'
import { http } from '../../http/http'
import { S2SConfig } from './s2sConfig.interface'

// TODO: To be replaced with a proper logging library
const logger = console

export async function postS2SLease(s2sConfig: S2SConfig): Promise<string> {
const oneTimePassword = totp({ secret: s2sConfig.s2sSecret })

logger.info('generating from secret: ', s2sConfig.s2sSecret, s2sConfig.microservice, oneTimePassword)

const request = await http.post(`${s2sConfig.s2sEndpointUrl}`, {
microservice: s2sConfig.microservice,
oneTimePassword,
})

return request.data
}
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"declaration": true,
"outDir": "./dist",
"strict": true,
"typeRoots": ["node_modules/@types"]
"typeRoots": ["node_modules/@types", "customTypes"]
},
"include": ["typings.d.ts", "src"],
"exclude": ["node_modules", "**/*.spec.ts"]
Expand Down
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4950,6 +4950,11 @@ node-notifier@^5.4.2:
shellwords "^0.1.1"
which "^1.3.0"

node-otp@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/node-otp/-/node-otp-1.2.2.tgz#d4e07ed2e1f4c64839be24d5b640efba351b9eb2"
integrity sha512-dYRBE4aiPnQ1SLFQoV1HGflpiWnyXCw3kAwN5r75j6ffJISAYTHQalowk61zRDFLK3YPd2xKbEV63nOf2RSv6w==

normalize-package-data@^2.3.0, normalize-package-data@^2.3.2, normalize-package-data@^2.3.4, normalize-package-data@^2.3.5:
version "2.5.0"
resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8"
Expand Down

0 comments on commit 08f5c14

Please sign in to comment.