Skip to content

Commit

Permalink
Merge pull request #6 from opengovsg/mockinfo
Browse files Browse the repository at this point in the history
Implement MockInfo
  • Loading branch information
LoneRifle authored Jan 14, 2019
2 parents 3871396 + 7895880 commit bbe83b0
Show file tree
Hide file tree
Showing 7 changed files with 6,266 additions and 7 deletions.
1 change: 1 addition & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const app = config(express(), {
assertEndpoint: process.env.CORPPASS_ASSERT_ENDPOINT,
},
},
port: PORT,
showLoginPage: process.env.SHOW_LOGIN_PAGE === 'true',
})

Expand Down
4 changes: 4 additions & 0 deletions lib/assertions.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const readFrom = p => fs.readFileSync(path.resolve(__dirname, p), 'utf8')
const TEMPLATE = readFrom('../static/saml/unsigned-assertion.xml')
const corpPassTemplate = readFrom('../static/saml/corppass.xml')

const myinfo = JSON.parse(readFrom('../static/myinfo.json'))

const identities = {
singPass: [
'S8979373D',
Expand Down Expand Up @@ -45,6 +47,7 @@ const identities = {
'F1612358R',
'F1612354N',
'F1612357U',
...Object.keys(myinfo.personas),
],
corpPass: [
{ NRIC: 'S8979373D', UEN: '123456789A' },
Expand Down Expand Up @@ -75,4 +78,5 @@ module.exports = {
create: makeCorpPass,
},
identities,
myinfo,
}
32 changes: 32 additions & 0 deletions lib/crypto.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
const crypto = require('crypto')
const fs = require('fs')
const path = require('path')
const { SignedXml } = require('xml-crypto')
Expand All @@ -13,6 +14,30 @@ module.exports = (serviceProvider) => {
keyEncryptionAlgorighm: 'http://www.w3.org/2001/04/xmlenc#rsa-1_5',
}

const baseString = function baseString ({
httpMethod,
url,
appId,
clientId,
singpassEserviceId,
nonce,
requestedAttributes,
timestamp,
}) {
return httpMethod.toUpperCase() +
// url string replacement was dictated by MyInfo docs - no explanation
// was provided for why this is necessary
'&' + url.replace('.api.gov.sg', '.e.api.gov.sg') +
'&apex_l2_eg_app_id=' + appId +
'&apex_l2_eg_nonce=' + nonce +
'&apex_l2_eg_signature_method=SHA256withRSA' +
'&apex_l2_eg_timestamp=' + timestamp +
'&apex_l2_eg_version=1.0' +
'&attributes=' + requestedAttributes +
'&client_id=' + clientId +
'&singpassEserviceId=' + singpassEserviceId
}

return {
verifySignature (xml, serviceProviderCertPath) {
const [ signature ] =
Expand Down Expand Up @@ -53,5 +78,12 @@ module.exports = (serviceProvider) => {
: resolve(`<saml:EncryptedAssertion>${data}</saml:EncryptedAssertion>`)
)
}),

verifyMyInfoSignature (signature, baseStringFields) {
const verifier = crypto.createVerify('RSA-SHA256')
verifier.update(baseString(baseStringFields))
verifier.end()
return verifier.verify(serviceProvider.pubKey, signature, 'base64')
},
}
}
74 changes: 70 additions & 4 deletions lib/express.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
const _ = require('lodash')
const bodyParser = require('body-parser')
const fs = require('fs')
const { pick, partition } = require('lodash')
const morgan = require('morgan')
const { render } = require('mustache')
const path = require('path')
Expand All @@ -16,23 +18,26 @@ const dom = xmlString => domParser.parseFromString(xmlString)
const TEMPLATE = fs.readFileSync(path.resolve(__dirname, '../static/saml/unsigned-response.xml'), 'utf8')
const LOGIN_TEMPLATE = fs.readFileSync(path.resolve(__dirname, '../static/html/login-page.html'), 'utf8')

function config (app, { showLoginPage, serviceProvider, idpConfig }) {
const { verifySignature, sign, promiseToEncryptAssertion } = crypto(serviceProvider)
function config (app, { showLoginPage, serviceProvider, idpConfig, port }) {
const { verifySignature, verifyMyInfoSignature, sign, promiseToEncryptAssertion } = crypto(serviceProvider)
app.use(morgan('combined'))

for (const idp of ['singPass', 'corpPass']) {
app.get(`/${idp.toLowerCase()}/logininitial`, (req, res) => {
const relayState = encodeURIComponent(req.query.Target)
if (showLoginPage) {
const identities = assertions.identities[idp]
const someValues = identities
const values = identities
.map((id, index) => {
const samlArt = samlArtifact(idpConfig[idp].id, index)
const assertURL =
`${idpConfig[idp].assertEndpoint}?SAMLart=${samlArt}&RelayState=${relayState}`
if (assertions.myinfo.personas[id]) {
id += ' [MyInfo]'
}
return { id, assertURL }
})
const response = render(LOGIN_TEMPLATE, someValues)
const response = render(LOGIN_TEMPLATE, values)
res.send(response)
} else {
const samlArt = samlArtifact(idpConfig[idp].id)
Expand Down Expand Up @@ -80,6 +85,67 @@ function config (app, { showLoginPage, serviceProvider, idpConfig }) {
}
)
}

const lookupPerson = allowedAttributes => (req, res) => {
const requestedAttributes = (req.query.attributes || '').split(',')

const [attributes, disallowedAttributes] = partition(
requestedAttributes,
v => allowedAttributes.includes(v)
)

if (disallowedAttributes.length > 0) {
res.status(401).send({ code: 401, message: 'Disallowed', fields: disallowedAttributes.join(',') })
} else {
const persona = assertions.myinfo.personas[req.params.uinfin]
res.status(persona ? 200 : 404)
.send(
persona
? pick(persona, attributes)
: { code: 404, message: 'UIN/FIN does not exist in MyInfo.', fields: '' }
)
}
}

const allowedAttributes = assertions.myinfo.attributes

app.get(
'/myinfo/person-basic/:uinfin/',
(req, res, next) => {
const [, authHeader] = req.get('Authorization').split(' ')
const authHeaderFieldPairs = _(authHeader)
.replace(/"/g, '')
.replace(/apex_l2_eg_/g, '')
.split(',')
.map(v => v.replace('=', '~').split('~'))

const authHeaderFields = _(authHeaderFieldPairs)
.fromPairs()
.mapKeys((v, k) => _.camelCase(k))
.value()

authHeaderFields.clientId = authHeaderFields.appId
authHeaderFields.singpassEserviceId = authHeaderFields.appId.replace(/^[^-]+-/, '')

authHeaderFields.httpMethod = req.method
// HACK: kludge the URL together
const hostPort = ['localhost', '127.0.0.1'].includes(req.hostname)
? `${req.hostname}:${port}`
: req.hostname

authHeaderFields.url = `http://${hostPort}${req.baseUrl}${req.path}`
authHeaderFields.requestedAttributes = req.query.attributes

if (verifyMyInfoSignature(authHeaderFields.signature, authHeaderFields)) {
next()
} else {
res.status(403).send({ code: 403, message: 'Digital Service is invalid', fields: '' })
}
},
lookupPerson(allowedAttributes.basic)
)
app.get('/myinfo/person/:uinfin/', lookupPerson([...allowedAttributes.basic, ...allowedAttributes.income]))

return app
}

Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"dependencies": {
"base-64": "^0.1.0",
"express": "^4.16.3",
"lodash": "^4.17.11",
"morgan": "^1.9.1",
"mustache": "^2.3.2",
"xml-crypto": "^1.1.1",
Expand Down
Loading

0 comments on commit bbe83b0

Please sign in to comment.