@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.
- package/.eslintrc.json +13 -0
- package/.gitattributes +2 -0
- package/.github/dependabot.yml +14 -0
- package/.github/mergify.yml +12 -0
- package/.github/workflows/ci.yml +27 -0
- package/.github/workflows/npmpublish.yml +22 -0
- package/.gitpod.yml +5 -0
- package/.husky/pre-commit +4 -0
- package/.husky/pre-push +4 -0
- package/.prettierrc.js +5 -0
- package/Dockerfile +11 -0
- package/LICENSE +21 -0
- package/README.md +99 -0
- package/commitlint.config.js +7 -0
- package/index.js +87 -0
- package/lib/assertions.js +319 -0
- package/lib/crypto/index.js +61 -0
- package/lib/crypto/myinfo-signature.js +153 -0
- package/lib/express/index.js +6 -0
- package/lib/express/myinfo/consent.js +160 -0
- package/lib/express/myinfo/controllers.js +179 -0
- package/lib/express/myinfo/index.js +10 -0
- package/lib/express/oidc.js +131 -0
- package/lib/express/saml.js +171 -0
- package/lib/express/sgid.js +168 -0
- package/lib/saml-artifact.js +32 -0
- package/package.json +81 -0
- package/public/mockpass/resources/css/animate.css +43 -0
- package/public/mockpass/resources/css/common.css +121 -0
- package/public/mockpass/resources/css/reset.css +95 -0
- package/public/mockpass/resources/css/style-baseline-small-media.css +567 -0
- package/public/mockpass/resources/css/style-baseline.css +1006 -0
- package/public/mockpass/resources/css/style-common-small-media.css +156 -0
- package/public/mockpass/resources/css/style-common.css +510 -0
- package/public/mockpass/resources/css/style-homepage-small-media.css +588 -0
- package/public/mockpass/resources/css/style-homepage.css +674 -0
- package/public/mockpass/resources/css/style-main.css +9 -0
- package/public/mockpass/resources/img/ajax-loader.gif +0 -0
- package/public/mockpass/resources/img/ask_cheryl_tab.png +0 -0
- package/public/mockpass/resources/img/background/large-device/sp_bg.jpg +0 -0
- package/public/mockpass/resources/img/background/medium-device/ipad-bg.jpg +0 -0
- package/public/mockpass/resources/img/background/medium-device/ipad-landscape-sp-bg.jpg +0 -0
- package/public/mockpass/resources/img/background/small-device/mobile-sp-bg.jpg +0 -0
- package/public/mockpass/resources/img/carousel/large-device/how-to-setup-2fa-icon.png +0 -0
- package/public/mockpass/resources/img/carousel/large-device/register-icon.png +0 -0
- package/public/mockpass/resources/img/carousel/large-device/reset-password-icon.png +0 -0
- package/public/mockpass/resources/img/carousel/large-device/setup-2fa-icon.png +0 -0
- package/public/mockpass/resources/img/carousel/large-device/update-acct-icon.png +0 -0
- package/public/mockpass/resources/img/carousel/medium-device/ipad-register-icon.png +0 -0
- package/public/mockpass/resources/img/carousel/medium-device/ipad-reset-password-icon.png +0 -0
- package/public/mockpass/resources/img/carousel/medium-device/ipad-setup-2fa-icon.png +0 -0
- package/public/mockpass/resources/img/carousel/medium-device/ipad-update-acct-icon.png +0 -0
- package/public/mockpass/resources/img/carousel/small-device/mobile-register.png +0 -0
- package/public/mockpass/resources/img/carousel/small-device/mobile-reset-password-icon.png +0 -0
- package/public/mockpass/resources/img/carousel/small-device/mobile-update-acct-icon.png +0 -0
- package/public/mockpass/resources/img/close.png +0 -0
- package/public/mockpass/resources/img/id-pw-icon.png +0 -0
- package/public/mockpass/resources/img/logo/mockpass-logo.png +0 -0
- package/public/mockpass/resources/img/logo/mockpass-placeholder-logo.png +0 -0
- package/public/mockpass/resources/img/logo/mockpass_watermark.png +0 -0
- package/public/mockpass/resources/img/qr-icon.png +0 -0
- package/public/mockpass/resources/img/qr-shadow.png +0 -0
- package/public/mockpass/resources/img/refresh.jpg +0 -0
- package/public/mockpass/resources/img/sidebar-icons.png +0 -0
- package/public/mockpass/resources/img/sp-qr-unavailable.png +0 -0
- package/public/mockpass/resources/img/utility-icon-black.png +0 -0
- package/public/mockpass/resources/js/bootstrap.min.js +7 -0
- package/public/mockpass/resources/js/jquery-3.5.1.js +10872 -0
- package/public/mockpass/resources/js/login-common.js +849 -0
- package/public/mockpass/resources/plugins/bootstrap-3.3.6/css/bootstrap.min.css +6 -0
- package/public/mockpass/resources/plugins/bootstrap-3.3.6/fonts/glyphicons-halflings-regular.woff2 +0 -0
- package/static/certs/csr.pem +17 -0
- package/static/certs/key.pem +28 -0
- package/static/certs/key.pub +9 -0
- package/static/certs/server.crt +21 -0
- package/static/certs/spcp-csr.pem +17 -0
- package/static/certs/spcp-key.pem +28 -0
- package/static/certs/spcp.crt +20 -0
- package/static/html/consent.html +40 -0
- package/static/html/login-page.html +271 -0
- package/static/myinfo/v2.json +6154 -0
- package/static/myinfo/v3.json +29386 -0
- package/static/saml/corppass.xml +21 -0
- package/static/saml/unsigned-assertion.xml +24 -0
- 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
|
+
'×tamp=' +
|
|
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
|
+
'×tamp=' +
|
|
149
|
+
timestamp,
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = { pki, apex }
|
|
@@ -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
|