@opengovsg/mockpass 2.7.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/.eslintrc.json +13 -0
  2. package/.gitattributes +2 -0
  3. package/.github/dependabot.yml +14 -0
  4. package/.github/mergify.yml +12 -0
  5. package/.github/workflows/ci.yml +27 -0
  6. package/.github/workflows/npmpublish.yml +22 -0
  7. package/.gitpod.yml +5 -0
  8. package/.husky/pre-commit +4 -0
  9. package/.husky/pre-push +4 -0
  10. package/.prettierrc.js +5 -0
  11. package/Dockerfile +11 -0
  12. package/LICENSE +21 -0
  13. package/README.md +99 -0
  14. package/commitlint.config.js +7 -0
  15. package/index.js +87 -0
  16. package/lib/assertions.js +319 -0
  17. package/lib/crypto/index.js +61 -0
  18. package/lib/crypto/myinfo-signature.js +153 -0
  19. package/lib/express/index.js +6 -0
  20. package/lib/express/myinfo/consent.js +160 -0
  21. package/lib/express/myinfo/controllers.js +179 -0
  22. package/lib/express/myinfo/index.js +10 -0
  23. package/lib/express/oidc.js +131 -0
  24. package/lib/express/saml.js +171 -0
  25. package/lib/express/sgid.js +168 -0
  26. package/lib/saml-artifact.js +32 -0
  27. package/package.json +81 -0
  28. package/public/mockpass/resources/css/animate.css +43 -0
  29. package/public/mockpass/resources/css/common.css +121 -0
  30. package/public/mockpass/resources/css/reset.css +95 -0
  31. package/public/mockpass/resources/css/style-baseline-small-media.css +567 -0
  32. package/public/mockpass/resources/css/style-baseline.css +1006 -0
  33. package/public/mockpass/resources/css/style-common-small-media.css +156 -0
  34. package/public/mockpass/resources/css/style-common.css +510 -0
  35. package/public/mockpass/resources/css/style-homepage-small-media.css +588 -0
  36. package/public/mockpass/resources/css/style-homepage.css +674 -0
  37. package/public/mockpass/resources/css/style-main.css +9 -0
  38. package/public/mockpass/resources/img/ajax-loader.gif +0 -0
  39. package/public/mockpass/resources/img/ask_cheryl_tab.png +0 -0
  40. package/public/mockpass/resources/img/background/large-device/sp_bg.jpg +0 -0
  41. package/public/mockpass/resources/img/background/medium-device/ipad-bg.jpg +0 -0
  42. package/public/mockpass/resources/img/background/medium-device/ipad-landscape-sp-bg.jpg +0 -0
  43. package/public/mockpass/resources/img/background/small-device/mobile-sp-bg.jpg +0 -0
  44. package/public/mockpass/resources/img/carousel/large-device/how-to-setup-2fa-icon.png +0 -0
  45. package/public/mockpass/resources/img/carousel/large-device/register-icon.png +0 -0
  46. package/public/mockpass/resources/img/carousel/large-device/reset-password-icon.png +0 -0
  47. package/public/mockpass/resources/img/carousel/large-device/setup-2fa-icon.png +0 -0
  48. package/public/mockpass/resources/img/carousel/large-device/update-acct-icon.png +0 -0
  49. package/public/mockpass/resources/img/carousel/medium-device/ipad-register-icon.png +0 -0
  50. package/public/mockpass/resources/img/carousel/medium-device/ipad-reset-password-icon.png +0 -0
  51. package/public/mockpass/resources/img/carousel/medium-device/ipad-setup-2fa-icon.png +0 -0
  52. package/public/mockpass/resources/img/carousel/medium-device/ipad-update-acct-icon.png +0 -0
  53. package/public/mockpass/resources/img/carousel/small-device/mobile-register.png +0 -0
  54. package/public/mockpass/resources/img/carousel/small-device/mobile-reset-password-icon.png +0 -0
  55. package/public/mockpass/resources/img/carousel/small-device/mobile-update-acct-icon.png +0 -0
  56. package/public/mockpass/resources/img/close.png +0 -0
  57. package/public/mockpass/resources/img/id-pw-icon.png +0 -0
  58. package/public/mockpass/resources/img/logo/mockpass-logo.png +0 -0
  59. package/public/mockpass/resources/img/logo/mockpass-placeholder-logo.png +0 -0
  60. package/public/mockpass/resources/img/logo/mockpass_watermark.png +0 -0
  61. package/public/mockpass/resources/img/qr-icon.png +0 -0
  62. package/public/mockpass/resources/img/qr-shadow.png +0 -0
  63. package/public/mockpass/resources/img/refresh.jpg +0 -0
  64. package/public/mockpass/resources/img/sidebar-icons.png +0 -0
  65. package/public/mockpass/resources/img/sp-qr-unavailable.png +0 -0
  66. package/public/mockpass/resources/img/utility-icon-black.png +0 -0
  67. package/public/mockpass/resources/js/bootstrap.min.js +7 -0
  68. package/public/mockpass/resources/js/jquery-3.5.1.js +10872 -0
  69. package/public/mockpass/resources/js/login-common.js +849 -0
  70. package/public/mockpass/resources/plugins/bootstrap-3.3.6/css/bootstrap.min.css +6 -0
  71. package/public/mockpass/resources/plugins/bootstrap-3.3.6/fonts/glyphicons-halflings-regular.woff2 +0 -0
  72. package/static/certs/csr.pem +17 -0
  73. package/static/certs/key.pem +28 -0
  74. package/static/certs/key.pub +9 -0
  75. package/static/certs/server.crt +21 -0
  76. package/static/certs/spcp-csr.pem +17 -0
  77. package/static/certs/spcp-key.pem +28 -0
  78. package/static/certs/spcp.crt +20 -0
  79. package/static/html/consent.html +40 -0
  80. package/static/html/login-page.html +271 -0
  81. package/static/myinfo/v2.json +6154 -0
  82. package/static/myinfo/v3.json +29386 -0
  83. package/static/saml/corppass.xml +21 -0
  84. package/static/saml/unsigned-assertion.xml +24 -0
  85. package/static/saml/unsigned-response.xml +19 -0
@@ -0,0 +1,61 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const { SignedXml } = require('xml-crypto')
4
+ const { encrypt } = require('xml-encryption')
5
+ const xpath = require('xpath')
6
+
7
+ module.exports = (serviceProvider) => {
8
+ // NOTE - the typo in keyEncryptionAlgorighm is deliberate
9
+ const ENCRYPT_OPTIONS = {
10
+ rsa_pub: serviceProvider.pubKey,
11
+ pem: serviceProvider.cert,
12
+ encryptionAlgorithm: 'http://www.w3.org/2001/04/xmlenc#aes256-cbc',
13
+ keyEncryptionAlgorithm: 'http://www.w3.org/2001/04/xmlenc#rsa-1_5',
14
+ }
15
+
16
+ return {
17
+ verifySignature(xml) {
18
+ const [signature] =
19
+ xpath.select("//*[local-name(.)='Signature']", xml) || []
20
+ const [artifactResolvePayload] =
21
+ xpath.select("//*[local-name(.)='ArtifactResolve']", xml) || []
22
+ const verifier = new SignedXml()
23
+ verifier.keyInfoProvider = { getKey: () => ENCRYPT_OPTIONS.pem }
24
+ verifier.loadSignature(signature.toString())
25
+ return verifier.checkSignature(artifactResolvePayload.toString())
26
+ },
27
+
28
+ sign(payload, reference) {
29
+ const sig = new SignedXml()
30
+ const transforms = [
31
+ 'http://www.w3.org/2000/09/xmldsig#enveloped-signature',
32
+ 'http://www.w3.org/2001/10/xml-exc-c14n#',
33
+ ]
34
+ const digestAlgorithm = 'http://www.w3.org/2001/04/xmlenc#sha256'
35
+ sig.addReference(reference, transforms, digestAlgorithm)
36
+
37
+ sig.signingKey = fs.readFileSync(
38
+ path.resolve(__dirname, '../../static/certs/spcp-key.pem'),
39
+ )
40
+ sig.signatureAlgorithm =
41
+ 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256'
42
+ const options = {
43
+ prefix: 'ds',
44
+ location: { reference, action: 'prepend' },
45
+ }
46
+ sig.computeSignature(payload, options)
47
+ return sig.getSignedXml()
48
+ },
49
+
50
+ promiseToEncryptAssertion: (assertion) =>
51
+ new Promise((resolve, reject) => {
52
+ encrypt(assertion, ENCRYPT_OPTIONS, (err, data) =>
53
+ err
54
+ ? reject(err)
55
+ : resolve(
56
+ `<saml:EncryptedAssertion>${data}</saml:EncryptedAssertion>`,
57
+ ),
58
+ )
59
+ }),
60
+ }
61
+ }
@@ -0,0 +1,153 @@
1
+ const _ = require('lodash')
2
+
3
+ const apex = function apex(authHeader, req, context = {}) {
4
+ const authHeaderFieldPairs = _(authHeader)
5
+ .replace(/"/g, '')
6
+ .replace(/apex_l2_eg_/g, '')
7
+ .split(',')
8
+ .map((v) => v.replace('=', '~').split('~'))
9
+
10
+ const authHeaderFields = _(authHeaderFieldPairs)
11
+ .fromPairs()
12
+ .mapKeys((v, k) => _.camelCase(k))
13
+ .value()
14
+
15
+ const url = `${req.protocol}://${req.get('host')}${req.baseUrl}${req.path}`
16
+
17
+ const { clientSecret, redirectURI } = context
18
+
19
+ const {
20
+ method: httpMethod,
21
+ query: { attributes, singpassEserviceId },
22
+ } = req
23
+
24
+ const { code } = req.body || {}
25
+
26
+ const {
27
+ signature,
28
+ appId,
29
+ appId: clientId,
30
+ nonce,
31
+ timestamp,
32
+ } = authHeaderFields
33
+
34
+ const baseString = req.path.endsWith('/token')
35
+ ? httpMethod.toUpperCase() +
36
+ // url string replacement was dictated by MyInfo docs - no explanation
37
+ // was provided for why this is necessary
38
+ '&' +
39
+ url.replace('.api.gov.sg', '.e.api.gov.sg') +
40
+ '&apex_l2_eg_app_id=' +
41
+ appId +
42
+ '&apex_l2_eg_nonce=' +
43
+ nonce +
44
+ '&apex_l2_eg_signature_method=SHA256withRSA' +
45
+ '&apex_l2_eg_timestamp=' +
46
+ timestamp +
47
+ '&apex_l2_eg_version=1.0' +
48
+ '&client_id=' +
49
+ clientId +
50
+ '&client_secret=' +
51
+ clientSecret +
52
+ '&code=' +
53
+ code +
54
+ '&grant_type=authorization_code' +
55
+ '&redirect_uri=' +
56
+ redirectURI
57
+ : httpMethod.toUpperCase() +
58
+ // url string replacement was dictated by MyInfo docs - no explanation
59
+ // was provided for why this is necessary
60
+ '&' +
61
+ url.replace('.api.gov.sg', '.e.api.gov.sg') +
62
+ '&apex_l2_eg_app_id=' +
63
+ appId +
64
+ '&apex_l2_eg_nonce=' +
65
+ nonce +
66
+ '&apex_l2_eg_signature_method=SHA256withRSA' +
67
+ '&apex_l2_eg_timestamp=' +
68
+ timestamp +
69
+ '&apex_l2_eg_version=1.0' +
70
+ '&attributes=' +
71
+ attributes +
72
+ '&client_id=' +
73
+ clientId +
74
+ (req.path.includes('/person-basic')
75
+ ? '&singpassEserviceId=' + singpassEserviceId
76
+ : '')
77
+
78
+ return {
79
+ signature,
80
+ baseString,
81
+ }
82
+ }
83
+
84
+ const pki = function pki(authHeader, req, context = {}) {
85
+ const authHeaderFieldPairs = _(authHeader)
86
+ .replace(/"/g, '')
87
+ .split(',')
88
+ .map((v) => v.replace('=', '~').split('~'))
89
+
90
+ const authHeaderFields = _(authHeaderFieldPairs)
91
+ .fromPairs()
92
+ .mapKeys((_v, k) => _.camelCase(k))
93
+ .value()
94
+
95
+ const url = `${req.protocol}://${req.get('host')}${req.baseUrl}${req.path}`
96
+
97
+ const { clientSecret, redirectURI } = context
98
+
99
+ const {
100
+ method: httpMethod,
101
+ query: { attributes, sp_esvcId },
102
+ } = req
103
+
104
+ const { code } = req.body || {}
105
+
106
+ const {
107
+ signature,
108
+ appId,
109
+ appId: clientId,
110
+ nonce,
111
+ timestamp,
112
+ } = authHeaderFields
113
+ return {
114
+ signature,
115
+ baseString: req.path.endsWith('/token')
116
+ ? httpMethod.toUpperCase() +
117
+ '&' +
118
+ url +
119
+ '&app_id=' +
120
+ appId +
121
+ '&client_id=' +
122
+ clientId +
123
+ '&client_secret=' +
124
+ clientSecret +
125
+ '&code=' +
126
+ code +
127
+ '&grant_type=authorization_code' +
128
+ '&nonce=' +
129
+ nonce +
130
+ '&redirect_uri=' +
131
+ redirectURI +
132
+ '&signature_method=RS256' +
133
+ '&timestamp=' +
134
+ timestamp
135
+ : httpMethod.toUpperCase() +
136
+ '&' +
137
+ url +
138
+ '&app_id=' +
139
+ appId +
140
+ '&attributes=' +
141
+ attributes +
142
+ '&client_id=' +
143
+ clientId +
144
+ '&nonce=' +
145
+ nonce +
146
+ '&signature_method=RS256' +
147
+ (req.path.includes('/person-basic') ? '&sp_esvcId=' + sp_esvcId : '') +
148
+ '&timestamp=' +
149
+ timestamp,
150
+ }
151
+ }
152
+
153
+ module.exports = { pki, apex }
@@ -0,0 +1,6 @@
1
+ module.exports = {
2
+ configSAML: require('./saml'),
3
+ configOIDC: require('./oidc'),
4
+ configMyInfo: require('./myinfo'),
5
+ configSGID: require('./sgid'),
6
+ }
@@ -0,0 +1,160 @@
1
+ const express = require('express')
2
+ const cookieParser = require('cookie-parser')
3
+ const fs = require('fs')
4
+ const { pick } = require('lodash')
5
+ const { render } = require('mustache')
6
+ const path = require('path')
7
+ const qs = require('querystring')
8
+ const { v1: uuid } = require('uuid')
9
+
10
+ const assertions = require('../../assertions')
11
+
12
+ const MYINFO_ASSERT_ENDPOINT = '/consent/myinfo-com'
13
+ const AUTHORIZE_ENDPOINT = '/consent/oauth2/authorize'
14
+ const CONSENT_TEMPLATE = fs.readFileSync(
15
+ path.resolve(__dirname, '../../../static/html/consent.html'),
16
+ 'utf8',
17
+ )
18
+
19
+ const authorizations = {}
20
+
21
+ const authorize = (redirectTo) => (req, res) => {
22
+ const {
23
+ client_id, // eslint-disable-line camelcase
24
+ redirect_uri, // eslint-disable-line camelcase
25
+ attributes,
26
+ purpose,
27
+ state,
28
+ } = req.query
29
+ const relayStateParams = qs.stringify({
30
+ client_id,
31
+ redirect_uri,
32
+ state,
33
+ purpose,
34
+ scope: (attributes || '').replace(/,/g, ' '),
35
+ realm: MYINFO_ASSERT_ENDPOINT,
36
+ response_type: 'code',
37
+ })
38
+ const relayState = `${AUTHORIZE_ENDPOINT}${encodeURIComponent(
39
+ '?' + relayStateParams,
40
+ )}`
41
+ res.redirect(redirectTo(relayState))
42
+ }
43
+
44
+ const authorizeViaSAML = authorize(
45
+ (relayState) =>
46
+ `/singpass/logininitial?esrvcID=MYINFO-CONSENTPLATFORM&PartnerId=${MYINFO_ASSERT_ENDPOINT}&Target=${relayState}`,
47
+ )
48
+
49
+ const authorizeViaOIDC = authorize(
50
+ (relayState) =>
51
+ `/singpass/authorize?client_id=MYINFO-CONSENTPLATFORM&redirect_uri=${MYINFO_ASSERT_ENDPOINT}&state=${relayState}`,
52
+ )
53
+
54
+ function config(app) {
55
+ app.get(MYINFO_ASSERT_ENDPOINT, (req, res) => {
56
+ const rawArtifact = req.query.SAMLart || req.query.code
57
+ const artifact = rawArtifact.replace(/ /g, '+')
58
+ const artifactBuffer = Buffer.from(artifact, 'base64')
59
+ const artifactMessage = artifactBuffer.toString('utf8', 24)
60
+ let index = artifactBuffer.readInt8(artifactBuffer.length - 1)
61
+
62
+ const state = req.query.RelayState || req.query.state
63
+ let id
64
+ if (artifactMessage.startsWith('customNric:')) {
65
+ id = artifactMessage.slice('customNric:'.length)
66
+ } else {
67
+ const assertionType = req.query.code ? 'oidc' : 'saml'
68
+
69
+ // use env NRIC when SHOW_LOGIN_PAGE is false
70
+ if (index === -1) {
71
+ index = assertions[assertionType].singPass.indexOf(
72
+ assertions.singPassNric,
73
+ )
74
+ }
75
+ id = assertions[assertionType].singPass[index]
76
+ }
77
+ const persona = assertions.myinfo[req.query.code ? 'v3' : 'v2'].personas[id]
78
+ if (!persona) {
79
+ res.status(404).send({
80
+ message: 'Cannot find MyInfo Persona',
81
+ artifact,
82
+ index,
83
+ id,
84
+ persona,
85
+ })
86
+ } else {
87
+ res.cookie('connect.sid', id)
88
+ res.redirect(state)
89
+ }
90
+ })
91
+
92
+ app.get(AUTHORIZE_ENDPOINT, cookieParser(), (req, res) => {
93
+ const params = {
94
+ ...req.query,
95
+ scope: req.query.scope.replace(/\+/g, ' '),
96
+ id: req.cookies['connect.sid'],
97
+ action: AUTHORIZE_ENDPOINT,
98
+ }
99
+
100
+ res.send(render(CONSENT_TEMPLATE, params))
101
+ })
102
+
103
+ app.post(
104
+ AUTHORIZE_ENDPOINT,
105
+ cookieParser(),
106
+ express.urlencoded({
107
+ extended: false,
108
+ type: 'application/x-www-form-urlencoded',
109
+ }),
110
+ (req, res) => {
111
+ const id = req.cookies['connect.sid']
112
+ const code = uuid()
113
+ authorizations[code] = [
114
+ {
115
+ sub: id,
116
+ auth_level: 0,
117
+ scope: req.body.scope.split(' '),
118
+ iss: `${req.protocol}://${req.get(
119
+ 'host',
120
+ )}/consent/oauth2/consent/myinfo-com`,
121
+ tokenName: 'access_token',
122
+ token_type: 'Bearer',
123
+ authGrantId: code,
124
+ auditTrackingId: code,
125
+ jti: code,
126
+ aud: 'myinfo',
127
+ grant_type: 'authorization_code',
128
+ realm: '/consent/myinfo-com',
129
+ },
130
+ req.body.redirect_uri,
131
+ ]
132
+ const callbackParams = qs.stringify(
133
+ req.body.decision === 'allow'
134
+ ? {
135
+ code,
136
+ ...pick(req.body, ['state', 'scope', 'client_id']),
137
+ iss: `${req.protocol}://${req.get(
138
+ 'host',
139
+ )}/consent/oauth2/consent/myinfo-com`,
140
+ }
141
+ : {
142
+ state: req.body.state,
143
+ 'error-description':
144
+ 'Resource Owner did not authorize the request',
145
+ error: 'access_denied',
146
+ },
147
+ )
148
+ res.redirect(`${req.body.redirect_uri}?${callbackParams}`)
149
+ },
150
+ )
151
+
152
+ return app
153
+ }
154
+
155
+ module.exports = {
156
+ authorizeViaSAML,
157
+ authorizeViaOIDC,
158
+ authorizations,
159
+ config,
160
+ }
@@ -0,0 +1,179 @@
1
+ const crypto = require('crypto')
2
+ const fs = require('fs')
3
+ const path = require('path')
4
+
5
+ const express = require('express')
6
+ const { pick, partition } = require('lodash')
7
+
8
+ const jose = require('node-jose')
9
+ const jwt = require('jsonwebtoken')
10
+
11
+ const assertions = require('../../assertions')
12
+ const consent = require('./consent')
13
+
14
+ const MOCKPASS_PRIVATE_KEY = fs.readFileSync(
15
+ path.resolve(__dirname, '../../../static/certs/spcp-key.pem'),
16
+ )
17
+ const MOCKPASS_PUBLIC_KEY = fs.readFileSync(
18
+ path.resolve(__dirname, '../../../static/certs/spcp.crt'),
19
+ )
20
+
21
+ const MYINFO_SECRET = process.env.SERVICE_PROVIDER_MYINFO_SECRET
22
+
23
+ module.exports =
24
+ (version, myInfoSignature) =>
25
+ (app, { serviceProvider, encryptMyInfo }) => {
26
+ const verify = (signature, baseString) => {
27
+ const verifier = crypto.createVerify('RSA-SHA256')
28
+ verifier.update(baseString)
29
+ verifier.end()
30
+ return verifier.verify(serviceProvider.pubKey, signature, 'base64')
31
+ }
32
+
33
+ const encryptPersona = async (persona) => {
34
+ const signedPersona =
35
+ version === 'v3'
36
+ ? jwt.sign(persona, MOCKPASS_PRIVATE_KEY, {
37
+ algorithm: 'RS256',
38
+ })
39
+ : persona
40
+ const serviceCertAsKey = await jose.JWK.asKey(serviceProvider.cert, 'pem')
41
+ const encryptedAndSignedPersona = await jose.JWE.createEncrypt(
42
+ { format: 'compact' },
43
+ serviceCertAsKey,
44
+ )
45
+ .update(JSON.stringify(signedPersona))
46
+ .final()
47
+ return encryptedAndSignedPersona
48
+ }
49
+
50
+ const lookupPerson = (allowedAttributes) => async (req, res) => {
51
+ const requestedAttributes = (req.query.attributes || '').split(',')
52
+
53
+ const [attributes, disallowedAttributes] = partition(
54
+ requestedAttributes,
55
+ (v) => allowedAttributes.includes(v),
56
+ )
57
+
58
+ if (disallowedAttributes.length > 0) {
59
+ res.status(401).send({
60
+ code: 401,
61
+ message: 'Disallowed',
62
+ fields: disallowedAttributes.join(','),
63
+ })
64
+ } else {
65
+ const transformPersona = encryptMyInfo
66
+ ? encryptPersona
67
+ : (person) => person
68
+ const persona = assertions.myinfo[version].personas[req.params.uinfin]
69
+ res.status(persona ? 200 : 404).send(
70
+ persona
71
+ ? await transformPersona(pick(persona, attributes))
72
+ : {
73
+ code: 404,
74
+ message: 'UIN/FIN does not exist in MyInfo.',
75
+ fields: '',
76
+ },
77
+ )
78
+ }
79
+ }
80
+
81
+ const allowedAttributes = assertions.myinfo[version].attributes
82
+
83
+ app.get(
84
+ `/myinfo/${version}/person-basic/:uinfin/`,
85
+ (req, res, next) => {
86
+ // sp_esvcId and txnNo needed as query params
87
+ const [, authHeader] = req.get('Authorization').split(' ')
88
+
89
+ const { signature, baseString } = myInfoSignature(authHeader, req)
90
+ if (verify(signature, baseString)) {
91
+ next()
92
+ } else {
93
+ res.status(403).send({
94
+ code: 403,
95
+ message: `Signature verification failed, ${baseString} does not result in ${signature}`,
96
+ fields: '',
97
+ })
98
+ }
99
+ },
100
+ lookupPerson(allowedAttributes.basic),
101
+ )
102
+ app.get(`/myinfo/${version}/person/:uinfin/`, (req, res) => {
103
+ const authz = req.get('Authorization').split(' ')
104
+ const token = authz.pop()
105
+
106
+ const authHeader = (authz[1] || '').replace(',Bearer', '')
107
+ const { signature, baseString } = encryptMyInfo
108
+ ? myInfoSignature(authHeader, req)
109
+ : {}
110
+
111
+ const { sub, scope } = jwt.verify(token, MOCKPASS_PUBLIC_KEY, {
112
+ algorithms: ['RS256'],
113
+ })
114
+ if (encryptMyInfo && !verify(signature, baseString)) {
115
+ res.status(401).send({
116
+ code: 401,
117
+ message: `Signature verification failed, ${baseString} does not result in ${signature}`,
118
+ })
119
+ } else if (sub !== req.params.uinfin) {
120
+ res.status(401).send({
121
+ code: 401,
122
+ message: 'UIN requested does not match logged in user',
123
+ })
124
+ } else {
125
+ lookupPerson(scope)(req, res)
126
+ }
127
+ })
128
+
129
+ app.get(
130
+ `/myinfo/${version}/authorise`,
131
+ version === 'v3' ? consent.authorizeViaOIDC : consent.authorizeViaSAML,
132
+ )
133
+
134
+ app.post(
135
+ `/myinfo/${version}/token`,
136
+ express.urlencoded({
137
+ extended: false,
138
+ type: 'application/x-www-form-urlencoded',
139
+ }),
140
+ (req, res) => {
141
+ const [tokenTemplate, redirectURI] =
142
+ consent.authorizations[req.body.code]
143
+ const [, authHeader] = (req.get('Authorization') || '').split(' ')
144
+
145
+ const { signature, baseString } = MYINFO_SECRET
146
+ ? myInfoSignature(authHeader, req, {
147
+ clientSecret: MYINFO_SECRET,
148
+ redirectURI,
149
+ })
150
+ : {}
151
+
152
+ if (!tokenTemplate) {
153
+ res.status(400).send({
154
+ code: 400,
155
+ message: 'No such authorization given',
156
+ fields: '',
157
+ })
158
+ } else if (MYINFO_SECRET && !verify(signature, baseString)) {
159
+ res.status(403).send({
160
+ code: 403,
161
+ message: `Signature verification failed, ${baseString} does not result in ${signature}`,
162
+ })
163
+ } else {
164
+ const token = jwt.sign(
165
+ { ...tokenTemplate, auth_time: Date.now() },
166
+ MOCKPASS_PRIVATE_KEY,
167
+ { expiresIn: '1800 seconds', algorithm: 'RS256' },
168
+ )
169
+ res.send({
170
+ access_token: token,
171
+ token_type: 'Bearer',
172
+ expires_in: 1798,
173
+ })
174
+ }
175
+ },
176
+ )
177
+
178
+ return app
179
+ }
@@ -0,0 +1,10 @@
1
+ const { config: consent } = require('./consent')
2
+ const controllers = require('./controllers')
3
+
4
+ const { pki, apex } = require('../../crypto/myinfo-signature')
5
+
6
+ module.exports = {
7
+ consent,
8
+ v2: controllers('v2', apex),
9
+ v3: controllers('v3', pki),
10
+ }
@@ -0,0 +1,131 @@
1
+ const express = require('express')
2
+ const fs = require('fs')
3
+ const { render } = require('mustache')
4
+ const jose = require('node-jose')
5
+ const path = require('path')
6
+ const ExpiryMap = require('expiry-map')
7
+
8
+ const assertions = require('../assertions')
9
+ const { samlArtifact } = require('../saml-artifact')
10
+
11
+ const LOGIN_TEMPLATE = fs.readFileSync(
12
+ path.resolve(__dirname, '../../static/html/login-page.html'),
13
+ 'utf8',
14
+ )
15
+ const NONCE_TIMEOUT = 5 * 60 * 1000
16
+ const nonceStore = new ExpiryMap(NONCE_TIMEOUT)
17
+
18
+ const signingPem = fs.readFileSync(
19
+ path.resolve(__dirname, '../../static/certs/spcp-key.pem'),
20
+ )
21
+
22
+ const idGenerator = {
23
+ singPass: (rawId) =>
24
+ assertions.myinfo.v3.personas[rawId] ? `${rawId} [MyInfo]` : rawId,
25
+ corpPass: (rawId) => `${rawId.nric} / UEN: ${rawId.uen}`,
26
+ }
27
+
28
+ function config(app, { showLoginPage, idpConfig, serviceProvider }) {
29
+ for (const idp of ['singPass', 'corpPass']) {
30
+ app.get(`/${idp.toLowerCase()}/authorize`, (req, res) => {
31
+ const redirectURI = req.query.redirect_uri
32
+ const state = encodeURIComponent(req.query.state)
33
+ if (showLoginPage) {
34
+ const oidc = assertions.oidc[idp]
35
+ const values = oidc.map((rawId, index) => {
36
+ const code = encodeURIComponent(
37
+ samlArtifact(idpConfig[idp].id, index),
38
+ )
39
+ if (req.query.nonce) {
40
+ nonceStore.set(code, req.query.nonce)
41
+ }
42
+ const assertURL = `${redirectURI}?code=${code}&state=${state}`
43
+ const id = idGenerator[idp](rawId)
44
+ return { id, assertURL }
45
+ })
46
+ const response = render(LOGIN_TEMPLATE, { values })
47
+ res.send(response)
48
+ } else {
49
+ const code = encodeURIComponent(samlArtifact(idpConfig[idp].id))
50
+ if (req.query.nonce) {
51
+ nonceStore.set(code, req.query.nonce)
52
+ }
53
+ const assertURL = `${redirectURI}?code=${code}&state=${state}`
54
+ console.warn(
55
+ `Redirecting login from ${req.query.client_id} to ${redirectURI}`,
56
+ )
57
+ res.redirect(assertURL)
58
+ }
59
+ })
60
+
61
+ app.post(
62
+ `/${idp.toLowerCase()}/token`,
63
+ express.urlencoded({ extended: false }),
64
+ async (req, res) => {
65
+ const { client_id: aud, grant_type: grant } = req.body
66
+ let nonce, uuid
67
+
68
+ if (grant === 'refresh_token') {
69
+ const { refresh_token: refreshToken } = req.body
70
+ console.warn(`Refreshing tokens with ${refreshToken}`)
71
+
72
+ uuid = refreshToken.split('/')[0]
73
+ } else {
74
+ const { code: artifact } = req.body
75
+ console.warn(
76
+ `Received artifact ${artifact} from ${aud} and ${req.body.redirect_uri}`,
77
+ )
78
+ const artifactBuffer = Buffer.from(artifact, 'base64')
79
+ uuid = artifactBuffer.readInt8(artifactBuffer.length - 1)
80
+ nonce = nonceStore.get(encodeURIComponent(artifact))
81
+ }
82
+
83
+ // use env NRIC when SHOW_LOGIN_PAGE is false
84
+ if (uuid === -1) {
85
+ uuid =
86
+ idp === 'singPass'
87
+ ? assertions.oidc.singPass.indexOf(assertions.singPassNric)
88
+ : assertions.oidc.corpPass.findIndex(
89
+ (c) => c.nric === assertions.corpPassNric,
90
+ )
91
+ }
92
+
93
+ const { idTokenClaims, accessToken, refreshToken } =
94
+ await assertions.oidc.create[idp](
95
+ uuid,
96
+ `${req.protocol}://${req.get('host')}`,
97
+ aud,
98
+ nonce,
99
+ )
100
+
101
+ const signingKey = await jose.JWK.asKey(signingPem, 'pem')
102
+ const signedIdToken = await jose.JWS.createSign(
103
+ { format: 'compact' },
104
+ signingKey,
105
+ )
106
+ .update(JSON.stringify(idTokenClaims))
107
+ .final()
108
+
109
+ const encryptionKey = await jose.JWK.asKey(serviceProvider.cert, 'pem')
110
+ const idToken = await jose.JWE.createEncrypt(
111
+ { format: 'compact', fields: { cty: 'JWT' } },
112
+ encryptionKey,
113
+ )
114
+ .update(signedIdToken)
115
+ .final()
116
+
117
+ res.send({
118
+ access_token: accessToken,
119
+ refresh_token: refreshToken,
120
+ expires_in: 24 * 60 * 60,
121
+ scope: 'openid',
122
+ token_type: 'bearer',
123
+ id_token: idToken,
124
+ })
125
+ },
126
+ )
127
+ }
128
+ return app
129
+ }
130
+
131
+ module.exports = config