Skip to content

Commit

Permalink
refactor: migrate SPCP /login endpoints to TypeScript (#680)
Browse files Browse the repository at this point in the history
  • Loading branch information
mantariksh committed Nov 23, 2020
1 parent 234179e commit ff1c0b6
Show file tree
Hide file tree
Showing 16 changed files with 471 additions and 839 deletions.
199 changes: 0 additions & 199 deletions src/app/controllers/spcp.server.controller.js
Original file line number Diff line number Diff line change
@@ -1,212 +1,13 @@
'use strict'

const config = require('../../config/config')
const formsgSdk = require('../../config/formsg-sdk')
const { isEmpty } = require('lodash')

const mongoose = require('mongoose')
const crypto = require('crypto')
const { StatusCodes } = require('http-status-codes')

const { createReqMeta } = require('../utils/request')
const logger = require('../../config/logger').createLoggerWithLabel(module)
const { mapDataToKey } = require('../../shared/util/verified-content')
const getFormModel = require('../models/form.server.model').default
const getLoginModel = require('../models/login.server.model').default
const Form = getFormModel(mongoose)
const Login = getLoginModel(mongoose)

const jwtNames = {
SP: 'jwtSp',
CP: 'jwtCp',
}
const destinationRegex = /^\/([\w]+)\/?/

const getForm = function (destination, cb) {
let formId = destinationRegex.exec(destination)[1]
Form.findById({ _id: formId })
.populate({
path: 'admin',
populate: {
path: 'agency',
model: 'Agency',
},
})
.exec(cb)
}

const destinationIsValid = function (destination) {
// Parse formId from the string
// e.g. "/5b9b5fcfdb4da66ef52b73b3/preview" -> "5b9b5fcfdb4da66ef52b73b3"
// with or without "/preview"
if (!destination || !destinationRegex.test(destination)) {
return false
}
return true
}

const isArtifactValid = function (idpPartnerEntityIds, samlArt, authType) {
// Artifact should be 44 bytes long, of type 0x0004 and
// source id should be SHA-1 hash of the issuer's entityID
let hexEncodedArtifact = Buffer.from(samlArt, 'base64').toString('hex')
let artifactHexLength = hexEncodedArtifact.length
let typeCode = parseInt(hexEncodedArtifact.substr(0, 4))
let sourceId = hexEncodedArtifact.substr(8, 40)
let hashedEntityId = crypto
.createHash('sha1')
.update(idpPartnerEntityIds[authType], 'utf8')
.digest('hex')

return (
artifactHexLength === 88 && typeCode === 4 && sourceId === hashedEntityId
)
}

const isValidAuthenticationQuery = (
destination,
idpPartnerEntityIds,
samlArt,
authType,
) => {
return (
destination &&
isArtifactValid(idpPartnerEntityIds, samlArt, authType) &&
destinationRegex.test(destination)
)
}

const handleOOBAuthenticationWith = (ndiConfig, authType, extractUser) => {
const {
authClients,
cpCookieMaxAge,
spCookieMaxAgePreserved,
spCookieMaxAge,
spcpCookieDomain,
idpPartnerEntityIds,
} = ndiConfig
return function (req, res) {
let authClient = authClients[authType] ? authClients[authType] : undefined
let jwtName = jwtNames[authType]
let { SAMLart: samlArt, RelayState: relayState } = req.query
const payloads = String(relayState).split(',')

if (payloads.length !== 2) {
return res.sendStatus(StatusCodes.BAD_REQUEST)
}

const destination = payloads[0]
const rememberMe = payloads[1] === 'true'

if (
!isValidAuthenticationQuery(
destination,
idpPartnerEntityIds,
samlArt,
authType,
)
) {
res.sendStatus(StatusCodes.UNAUTHORIZED)
return
}

// Resolve known express req.query issue where pluses become spaces
samlArt = String(samlArt).replace(/ /g, '+')

if (!destinationIsValid(destination))
return res.sendStatus(StatusCodes.BAD_REQUEST)

getForm(destination, (err, form) => {
if (err || !form || form.authType !== authType) {
res.sendStatus(StatusCodes.NOT_FOUND)
return
}
authClient.getAttributes(samlArt, destination, (err, data) => {
if (err) {
logger.error({
message: 'Error retrieving attributes from auth client',
meta: {
action: 'handleOOBAuthenticationWith',
...createReqMeta(req),
},
error: err,
})
}
const { attributes } = data
const { userName, userInfo } = extractUser(attributes)
if (userName && destination) {
// Create JWT
let payload = {
userName,
userInfo,
rememberMe,
}

let cookieDuration
if (authType === 'CP') {
cookieDuration = cpCookieMaxAge
} else {
cookieDuration = rememberMe
? spCookieMaxAgePreserved
: spCookieMaxAge
}

let jwt = authClient.createJWT(
payload,
cookieDuration / 1000,
// NOTE: cookieDuration is interpreted as a seconds count if numeric.
)
// Add login to DB
Login.addLoginFromForm(form).then(() => {
const spcpSettings = spcpCookieDomain
? { domain: spcpCookieDomain, path: '/' }
: {}
// Redirect to form
res.cookie(jwtName, jwt, {
maxAge: cookieDuration,
httpOnly: false, // the JWT needs to be read by client-side JS
sameSite: 'lax', // Setting to 'strict' prevents Singpass login on Safari, Firefox
secure: !config.isDev,
...spcpSettings,
})
res.redirect(destination)
})
} else if (destination) {
res.cookie('isLoginError', true)
res.redirect(destination)
} else {
res.redirect('/')
}
})
})
}
}

/**
* Assertion Consumer Endpoint - Authenticates form-filler with SingPass and creates session
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
exports.singPassLogin = (ndiConfig) => {
return handleOOBAuthenticationWith(ndiConfig, 'SP', (attributes) => {
let userName = attributes && attributes.UserName
return userName ? { userName } : {}
})
}

/**
* Assertion Consumer Endpoint - Authenticates form-filler with CorpPass and creates session
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
exports.corpPassLogin = (ndiConfig) => {
return handleOOBAuthenticationWith(ndiConfig, 'CP', (attributes) => {
let userName =
attributes && attributes.UserInfo && attributes.UserInfo.CPEntID
let userInfo =
attributes && attributes.UserInfo && attributes.UserInfo.CPUID
return userName && userInfo ? { userName, userInfo } : {}
})
}

/**
* Encrypt and sign verified fields if exist
Expand Down
67 changes: 0 additions & 67 deletions src/app/factories/spcp.factory.js
Original file line number Diff line number Diff line change
@@ -1,81 +1,14 @@
const spcp = require('../controllers/spcp.server.controller')
const { StatusCodes } = require('http-status-codes')
const featureManager = require('../../config/feature-manager').default
const fs = require('fs')
const SPCPAuthClient = require('@opengovsg/spcp-auth-client')
const logger = require('../../config/logger').createLoggerWithLabel(module)

const spcpFactory = ({ isEnabled, props }) => {
if (isEnabled && props) {
logger.info({
message: 'Configuring SingPass client...',
meta: {
action: 'spcpFactory',
},
})
let singPassAuthClient = new SPCPAuthClient({
partnerEntityId: props.spPartnerEntityId,
idpLoginURL: props.spIdpLoginUrl,
idpEndpoint: props.spIdpEndpoint,
esrvcID: props.spEsrvcId,
appKey: fs.readFileSync(props.spFormSgKeyPath),
appCert: fs.readFileSync(props.spFormSgCertPath),
spcpCert: fs.readFileSync(props.spIdpCertPath),
extract: SPCPAuthClient.extract.SINGPASS,
})
logger.info({
message: 'Configuring CorpPass client...',
meta: {
action: 'spcpFactory',
},
})
let corpPassAuthClient = new SPCPAuthClient({
partnerEntityId: props.cpPartnerEntityId,
idpLoginURL: props.cpIdpLoginUrl,
idpEndpoint: props.cpIdpEndpoint,
esrvcID: props.cpEsrvcId,
appKey: fs.readFileSync(props.cpFormSgKeyPath),
appCert: fs.readFileSync(props.cpFormSgCertPath),
spcpCert: fs.readFileSync(props.cpIdpCertPath),
extract: SPCPAuthClient.extract.CORPPASS,
})
logger.info({
message: 'Configuring MyInfo client...',
meta: {
action: 'spcpFactory',
},
})

const authClients = {
SP: singPassAuthClient,
CP: corpPassAuthClient,
}

const ndiConfig = {
authClients,
cpCookieMaxAge: props.cpCookieMaxAge,
spCookieMaxAgePreserved: props.spCookieMaxAgePreserved,
spCookieMaxAge: props.spCookieMaxAge,
spcpCookieDomain: props.spcpCookieDomain,
idpPartnerEntityIds: {
SP: props.spIdpId,
CP: props.cpIdpId,
},
}

return {
appendVerifiedSPCPResponses: spcp.appendVerifiedSPCPResponses,
singPassLogin: spcp.singPassLogin(ndiConfig),
corpPassLogin: spcp.corpPassLogin(ndiConfig),
}
} else {
const errMsg = 'SPCP/MyInfo feature is not enabled'
return {
appendVerifiedSPCPResponses: (req, res, next) => next(),
singPassLogin: (req, res) =>
res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ message: errMsg }),
corpPassLogin: (req, res) =>
res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ message: errMsg }),
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/app/modules/spcp/__tests__/spcp.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const MOCK_RESPONSE = expressHandler.mockResponse()
const MOCK_REDIRECT_REQ = expressHandler.mockRequest({
query: {
target: MOCK_TARGET,
authType: AuthType.SP,
authType: AuthType.SP as const,
esrvcId: MOCK_ESRVCID,
},
})
Expand Down
1 change: 1 addition & 0 deletions src/app/modules/spcp/__tests__/spcp.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ jest.mock('@opengovsg/spcp-auth-client')
const MockAuthClient = mocked(SPCPAuthClient, true)
jest.mock('fs', () => ({
readFileSync: jest.fn().mockImplementation((v) => v),
accessSync: jest.requireActual('fs').accessSync,
}))
jest.mock('axios')
const MockAxios = mocked(axios, true)
Expand Down
Loading

0 comments on commit ff1c0b6

Please sign in to comment.