@opengovsg/mockpass 2.7.11 → 2.9.0
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/README.md +5 -3
- package/index.js +2 -1
- package/lib/assertions.js +106 -106
- package/lib/auth-code.js +17 -0
- package/lib/express/myinfo/consent.js +15 -21
- package/lib/express/oidc.js +86 -50
- package/lib/express/saml.js +75 -51
- package/lib/express/sgid.js +39 -50
- package/lib/saml-artifact.js +20 -13
- package/package.json +4 -4
- package/public/mockpass/resources/js/login-common.js +0 -25
- package/static/html/login-page.html +40 -25
- package/static/myinfo/v3.json +7 -1
package/README.md
CHANGED
|
@@ -56,10 +56,12 @@ $ export CORPPASS_ASSERT_ENDPOINT=http://localhost:5000/corppass/assert
|
|
|
56
56
|
|
|
57
57
|
# All values shown here are defaults
|
|
58
58
|
$ export MOCKPASS_PORT=5156
|
|
59
|
-
$ export MOCKPASS_NRIC=S8979373D
|
|
60
|
-
$ export MOCKPASS_UEN=123456789A
|
|
61
59
|
|
|
62
|
-
$ export SHOW_LOGIN_PAGE=true # Optional, defaults to `false`
|
|
60
|
+
$ export SHOW_LOGIN_PAGE=true # Optional, defaults to `false`; can be overridden per request using `X-Show-Login-Page` HTTP header
|
|
61
|
+
|
|
62
|
+
# Configure which profile to return when login page is disabled
|
|
63
|
+
# Can be overridden per request using `X-Custom-NRIC`/`X-Custom-UUID`/`X-Custom-UEN` HTTP headers
|
|
64
|
+
$ export MOCKPASS_NRIC=S8979373D # Optional, defaults to first profile
|
|
63
65
|
|
|
64
66
|
# Disable signing/encryption (Optional, by default `true`)
|
|
65
67
|
$ export SIGN_ASSERTION=false
|
package/index.js
CHANGED
|
@@ -61,7 +61,8 @@ const options = {
|
|
|
61
61
|
assertEndpoint: process.env.CORPPASS_ASSERT_ENDPOINT,
|
|
62
62
|
},
|
|
63
63
|
},
|
|
64
|
-
showLoginPage:
|
|
64
|
+
showLoginPage: (req) =>
|
|
65
|
+
(req.header('X-Show-Login-Page') || process.env.SHOW_LOGIN_PAGE) === 'true',
|
|
65
66
|
encryptMyInfo: process.env.ENCRYPT_MYINFO === 'true',
|
|
66
67
|
cryptoConfig,
|
|
67
68
|
}
|
package/lib/assertions.js
CHANGED
|
@@ -19,17 +19,6 @@ const signingPem = fs.readFileSync(
|
|
|
19
19
|
path.resolve(__dirname, '../static/certs/spcp-key.pem'),
|
|
20
20
|
)
|
|
21
21
|
|
|
22
|
-
const createRefreshToken = (uuid) => {
|
|
23
|
-
const prefixSize = (`${uuid}`.length + 1) / 2
|
|
24
|
-
const padding = Number.isInteger(prefixSize) ? '/' : '/f'
|
|
25
|
-
|
|
26
|
-
return (
|
|
27
|
-
uuid +
|
|
28
|
-
padding +
|
|
29
|
-
crypto.randomBytes(20 - Math.floor(prefixSize)).toString('hex')
|
|
30
|
-
)
|
|
31
|
-
}
|
|
32
|
-
|
|
33
22
|
const hashToken = (token) => {
|
|
34
23
|
const fullHash = crypto.createHash('sha256')
|
|
35
24
|
fullHash.update(token, 'utf8')
|
|
@@ -38,8 +27,8 @@ const hashToken = (token) => {
|
|
|
38
27
|
if (Buffer.isEncoding('base64url')) {
|
|
39
28
|
return digestBuffer.toString('base64url')
|
|
40
29
|
} else {
|
|
41
|
-
const fromBase64 = (
|
|
42
|
-
|
|
30
|
+
const fromBase64 = (base64String) =>
|
|
31
|
+
base64String.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
|
|
43
32
|
return fromBase64(digestBuffer.toString('base64'))
|
|
44
33
|
}
|
|
45
34
|
}
|
|
@@ -51,42 +40,42 @@ const myinfo = {
|
|
|
51
40
|
|
|
52
41
|
const saml = {
|
|
53
42
|
singPass: [
|
|
54
|
-
'S8979373D',
|
|
55
|
-
'S8116474F',
|
|
56
|
-
'S8723211E',
|
|
57
|
-
'S5062854Z',
|
|
58
|
-
'T0066846F',
|
|
59
|
-
'F9477325W',
|
|
60
|
-
'S3000024B',
|
|
61
|
-
'S6005040F',
|
|
62
|
-
'S6005041D',
|
|
63
|
-
'S6005042B',
|
|
64
|
-
'S6005043J',
|
|
65
|
-
'S6005044I',
|
|
66
|
-
'S6005045G',
|
|
67
|
-
'S6005046E',
|
|
68
|
-
'S6005047C',
|
|
69
|
-
'S6005064C',
|
|
70
|
-
'S6005065A',
|
|
71
|
-
'S6005066Z',
|
|
72
|
-
'S6005037F',
|
|
73
|
-
'S6005038D',
|
|
74
|
-
'S6005039B',
|
|
75
|
-
'G1612357P',
|
|
76
|
-
'G1612358M',
|
|
77
|
-
'F1612359P',
|
|
78
|
-
'F1612360U',
|
|
79
|
-
'F1612361R',
|
|
80
|
-
'F1612362P',
|
|
81
|
-
'F1612363M',
|
|
82
|
-
'F1612364K',
|
|
83
|
-
'F1612365W',
|
|
84
|
-
'F1612366T',
|
|
85
|
-
'F1612367Q',
|
|
86
|
-
'F1612358R',
|
|
87
|
-
'F1612354N',
|
|
88
|
-
'F1612357U',
|
|
89
|
-
...Object.keys(myinfo.v2.personas),
|
|
43
|
+
{ nric: 'S8979373D' },
|
|
44
|
+
{ nric: 'S8116474F' },
|
|
45
|
+
{ nric: 'S8723211E' },
|
|
46
|
+
{ nric: 'S5062854Z' },
|
|
47
|
+
{ nric: 'T0066846F' },
|
|
48
|
+
{ nric: 'F9477325W' },
|
|
49
|
+
{ nric: 'S3000024B' },
|
|
50
|
+
{ nric: 'S6005040F' },
|
|
51
|
+
{ nric: 'S6005041D' },
|
|
52
|
+
{ nric: 'S6005042B' },
|
|
53
|
+
{ nric: 'S6005043J' },
|
|
54
|
+
{ nric: 'S6005044I' },
|
|
55
|
+
{ nric: 'S6005045G' },
|
|
56
|
+
{ nric: 'S6005046E' },
|
|
57
|
+
{ nric: 'S6005047C' },
|
|
58
|
+
{ nric: 'S6005064C' },
|
|
59
|
+
{ nric: 'S6005065A' },
|
|
60
|
+
{ nric: 'S6005066Z' },
|
|
61
|
+
{ nric: 'S6005037F' },
|
|
62
|
+
{ nric: 'S6005038D' },
|
|
63
|
+
{ nric: 'S6005039B' },
|
|
64
|
+
{ nric: 'G1612357P' },
|
|
65
|
+
{ nric: 'G1612358M' },
|
|
66
|
+
{ nric: 'F1612359P' },
|
|
67
|
+
{ nric: 'F1612360U' },
|
|
68
|
+
{ nric: 'F1612361R' },
|
|
69
|
+
{ nric: 'F1612362P' },
|
|
70
|
+
{ nric: 'F1612363M' },
|
|
71
|
+
{ nric: 'F1612364K' },
|
|
72
|
+
{ nric: 'F1612365W' },
|
|
73
|
+
{ nric: 'F1612366T' },
|
|
74
|
+
{ nric: 'F1612367Q' },
|
|
75
|
+
{ nric: 'F1612358R' },
|
|
76
|
+
{ nric: 'F1612354N' },
|
|
77
|
+
{ nric: 'F1612357U' },
|
|
78
|
+
...Object.keys(myinfo.v2.personas).map((nric) => ({ nric })),
|
|
90
79
|
],
|
|
91
80
|
corpPass: [
|
|
92
81
|
{ nric: 'S8979373D', uen: '123456789A' },
|
|
@@ -100,7 +89,7 @@ const saml = {
|
|
|
100
89
|
],
|
|
101
90
|
create: {
|
|
102
91
|
singPass: (
|
|
103
|
-
nric,
|
|
92
|
+
{ nric },
|
|
104
93
|
issuer,
|
|
105
94
|
recipient,
|
|
106
95
|
inResponseTo,
|
|
@@ -116,7 +105,7 @@ const saml = {
|
|
|
116
105
|
audience,
|
|
117
106
|
}),
|
|
118
107
|
corpPass: (
|
|
119
|
-
|
|
108
|
+
{ nric, uen },
|
|
120
109
|
issuer,
|
|
121
110
|
recipient,
|
|
122
111
|
inResponseTo,
|
|
@@ -124,8 +113,8 @@ const saml = {
|
|
|
124
113
|
) =>
|
|
125
114
|
render(TEMPLATE, {
|
|
126
115
|
issueInstant: moment.utc().format(),
|
|
127
|
-
name:
|
|
128
|
-
value: base64.encode(render(corpPassTemplate,
|
|
116
|
+
name: uen,
|
|
117
|
+
value: base64.encode(render(corpPassTemplate, { nric, uen })),
|
|
129
118
|
recipient,
|
|
130
119
|
issuer,
|
|
131
120
|
inResponseTo,
|
|
@@ -136,88 +125,99 @@ const saml = {
|
|
|
136
125
|
|
|
137
126
|
const oidc = {
|
|
138
127
|
singPass: [
|
|
139
|
-
'S8979373D',
|
|
140
|
-
'S8116474F',
|
|
141
|
-
'S8723211E',
|
|
142
|
-
'S5062854Z',
|
|
143
|
-
'T0066846F',
|
|
144
|
-
'F9477325W',
|
|
145
|
-
'S3000024B',
|
|
146
|
-
'S6005040F',
|
|
147
|
-
'S6005041D',
|
|
148
|
-
'S6005042B',
|
|
149
|
-
'S6005043J',
|
|
150
|
-
'S6005044I',
|
|
151
|
-
'S6005045G',
|
|
152
|
-
'S6005046E',
|
|
153
|
-
'S6005047C',
|
|
154
|
-
'S6005064C',
|
|
155
|
-
'S6005065A',
|
|
156
|
-
'S6005066Z',
|
|
157
|
-
'S6005037F',
|
|
158
|
-
'S6005038D',
|
|
159
|
-
'S6005039B',
|
|
160
|
-
'G1612357P',
|
|
161
|
-
'G1612358M',
|
|
162
|
-
'F1612359P',
|
|
163
|
-
'F1612360U',
|
|
164
|
-
'F1612361R',
|
|
165
|
-
'F1612362P',
|
|
166
|
-
'F1612363M',
|
|
167
|
-
'F1612364K',
|
|
168
|
-
'F1612365W',
|
|
169
|
-
'F1612366T',
|
|
170
|
-
'F1612367Q',
|
|
171
|
-
'F1612358R',
|
|
172
|
-
'F1612354N',
|
|
173
|
-
'F1612357U',
|
|
174
|
-
...Object.keys(myinfo.v3.personas)
|
|
128
|
+
{ nric: 'S8979373D', uuid: 'a9865837-7bd7-46ac-bef4-42a76a946424' },
|
|
129
|
+
{ nric: 'S8116474F', uuid: 'f4b70aea-d639-4b79-b8d9-8ace5875f6b1' },
|
|
130
|
+
{ nric: 'S8723211E', uuid: '178478de-fed7-4c03-a75e-e68c44d0d5f0' },
|
|
131
|
+
{ nric: 'S5062854Z', uuid: '1bd2e743-8681-4079-a557-6a66a8d16386' },
|
|
132
|
+
{ nric: 'T0066846F', uuid: '14f7ee8f-9e64-4170-a529-e55ca7578e2b' },
|
|
133
|
+
{ nric: 'F9477325W', uuid: '2135fe5c-d07b-49d3-b960-aabb0ff2e05a' },
|
|
134
|
+
{ nric: 'S3000024B', uuid: 'b5630beb-e3ee-4a31-aec5-534cdc087fd8' },
|
|
135
|
+
{ nric: 'S6005040F', uuid: '6c6745d9-e6c5-40ee-8c96-5d737ddbc5e4' },
|
|
136
|
+
{ nric: 'S6005041D', uuid: 'bd3fd1e0-c807-4b07-bbe4-b567cab54b8c' },
|
|
137
|
+
{ nric: 'S6005042B', uuid: '2dd788c0-d11f-4d5b-99af-b89d2389b474' },
|
|
138
|
+
{ nric: 'S6005043J', uuid: 'eb196477-36b3-4c0f-ae5e-2172e2f6a6d8' },
|
|
139
|
+
{ nric: 'S6005044I', uuid: '843ebc6b-1de1-4d46-b1dd-9ad4aeac3a27' },
|
|
140
|
+
{ nric: 'S6005045G', uuid: 'caafaedc-f369-498a-9e35-27e9cb7f0de2' },
|
|
141
|
+
{ nric: 'S6005046E', uuid: 'f9b37d06-de3f-4c4f-8331-37a3b2ee6cb4' },
|
|
142
|
+
{ nric: 'S6005047C', uuid: '57620e0f-fdf9-4f3e-a8f6-f6088e151395' },
|
|
143
|
+
{ nric: 'S6005064C', uuid: '80952b2f-3455-4b59-b50f-39afbc418271' },
|
|
144
|
+
{ nric: 'S6005065A', uuid: '3af48e26-69a1-43e3-b5f2-303098ef3210' },
|
|
145
|
+
{ nric: 'S6005066Z', uuid: '8b2f8213-2fe9-493a-ac95-0b55e319e689' },
|
|
146
|
+
{ nric: 'S6005037F', uuid: 'ae3d1d8c-6d14-449e-8ed1-9ce3d5e67607' },
|
|
147
|
+
{ nric: 'S6005038D', uuid: '23d3bb45-a324-46d6-b0d9-2e94194ed9ae' },
|
|
148
|
+
{ nric: 'S6005039B', uuid: '9ac807a2-5217-417a-a8d1-d7018b002b3f' },
|
|
149
|
+
{ nric: 'G1612357P', uuid: 'eb125a02-3137-486f-9262-eab3e0c57a5f' },
|
|
150
|
+
{ nric: 'G1612358M', uuid: 'd821900c-663d-4552-a753-a2e1cf8d124f' },
|
|
151
|
+
{ nric: 'F1612359P', uuid: '08df8d35-600c-45fd-a812-b37a27b7856a' },
|
|
152
|
+
{ nric: 'F1612360U', uuid: '1e90b698-23af-4acb-9fb4-eb5a80f444b6' },
|
|
153
|
+
{ nric: 'F1612361R', uuid: 'bc134ee1-f104-4b26-9839-32047fecb963' },
|
|
154
|
+
{ nric: 'F1612362P', uuid: '285e8366-f3bd-48b4-8153-b47260fc9f56' },
|
|
155
|
+
{ nric: 'F1612363M', uuid: '379bc106-d3db-492c-a38e-fd27642ef47f' },
|
|
156
|
+
{ nric: 'F1612364K', uuid: '108fa3ff-c85c-461e-ba1f-8edef62b68e2' },
|
|
157
|
+
{ nric: 'F1612365W', uuid: '1275ae4e-02d2-4b09-9573-36ac610ede89' },
|
|
158
|
+
{ nric: 'F1612366T', uuid: '23c6a3a4-d9d8-445f-a588-9d91831980a6' },
|
|
159
|
+
{ nric: 'F1612367Q', uuid: '0c400961-eb00-425a-8df4-6656b0b9245a' },
|
|
160
|
+
{ nric: 'F1612358R', uuid: '45669f5c-e9ac-43c6-bcd2-9c3757f1fa1c' },
|
|
161
|
+
{ nric: 'F1612354N', uuid: 'c38ddb2d-9e5d-45c2-bb70-8ccb54fc8320' },
|
|
162
|
+
{ nric: 'F1612357U', uuid: 'f904a2b1-4b61-47e2-bdad-e2d606325e20' },
|
|
163
|
+
...Object.keys(myinfo.v3.personas).map((nric) => ({
|
|
164
|
+
nric,
|
|
165
|
+
uuid: myinfo.v3.personas[nric].uuid.value,
|
|
166
|
+
})),
|
|
175
167
|
],
|
|
176
168
|
corpPass: [
|
|
177
169
|
{
|
|
178
170
|
nric: 'S8979373D',
|
|
171
|
+
uuid: 'a9865837-7bd7-46ac-bef4-42a76a946424',
|
|
179
172
|
name: 'Name of S8979373D',
|
|
180
173
|
isSingPassHolder: true,
|
|
181
174
|
uen: '123456789A',
|
|
182
175
|
},
|
|
183
176
|
{
|
|
184
177
|
nric: 'S8116474F',
|
|
178
|
+
uuid: 'f4b70aea-d639-4b79-b8d9-8ace5875f6b1',
|
|
185
179
|
name: 'Name of S8116474F',
|
|
186
180
|
isSingPassHolder: true,
|
|
187
181
|
uen: '123456789A',
|
|
188
182
|
},
|
|
189
183
|
{
|
|
190
184
|
nric: 'S8723211E',
|
|
185
|
+
uuid: '178478de-fed7-4c03-a75e-e68c44d0d5f0',
|
|
191
186
|
name: 'Name of S8723211E',
|
|
192
187
|
isSingPassHolder: true,
|
|
193
188
|
uen: '123456789A',
|
|
194
189
|
},
|
|
195
190
|
{
|
|
196
191
|
nric: 'S5062854Z',
|
|
192
|
+
uuid: '1bd2e743-8681-4079-a557-6a66a8d16386',
|
|
197
193
|
name: 'Name of S5062854Z',
|
|
198
194
|
isSingPassHolder: true,
|
|
199
195
|
uen: '123456789B',
|
|
200
196
|
},
|
|
201
197
|
{
|
|
202
198
|
nric: 'T0066846F',
|
|
199
|
+
uuid: '14f7ee8f-9e64-4170-a529-e55ca7578e2b',
|
|
203
200
|
name: 'Name of T0066846F',
|
|
204
201
|
isSingPassHolder: true,
|
|
205
202
|
uen: '123456789B',
|
|
206
203
|
},
|
|
207
204
|
{
|
|
208
205
|
nric: 'F9477325W',
|
|
206
|
+
uuid: '2135fe5c-d07b-49d3-b960-aabb0ff2e05a',
|
|
209
207
|
name: 'Name of F9477325W',
|
|
210
208
|
isSingPassHolder: false,
|
|
211
209
|
uen: '123456789B',
|
|
212
210
|
},
|
|
213
211
|
{
|
|
214
212
|
nric: 'S3000024B',
|
|
213
|
+
uuid: 'b5630beb-e3ee-4a31-aec5-534cdc087fd8',
|
|
215
214
|
name: 'Name of S3000024B',
|
|
216
215
|
isSingPassHolder: true,
|
|
217
216
|
uen: '123456789C',
|
|
218
217
|
},
|
|
219
218
|
{
|
|
220
219
|
nric: 'S6005040F',
|
|
220
|
+
uuid: '6c6745d9-e6c5-40ee-8c96-5d737ddbc5e4',
|
|
221
221
|
name: 'Name of S6005040F',
|
|
222
222
|
isSingPassHolder: true,
|
|
223
223
|
uen: '123456789C',
|
|
@@ -225,17 +225,17 @@ const oidc = {
|
|
|
225
225
|
],
|
|
226
226
|
create: {
|
|
227
227
|
singPass: (
|
|
228
|
-
uuid,
|
|
228
|
+
{ nric, uuid },
|
|
229
229
|
iss,
|
|
230
230
|
aud,
|
|
231
231
|
nonce,
|
|
232
232
|
accessToken = crypto.randomBytes(15).toString('hex'),
|
|
233
233
|
) => {
|
|
234
|
-
const nric = oidc.singPass[uuid]
|
|
235
234
|
const sub = `s=${nric},u=${uuid}`
|
|
236
235
|
|
|
237
|
-
const refreshToken = createRefreshToken(uuid)
|
|
238
236
|
const accessTokenHash = hashToken(accessToken)
|
|
237
|
+
|
|
238
|
+
const refreshToken = crypto.randomBytes(20).toString('hex')
|
|
239
239
|
const refreshTokenHash = hashToken(refreshToken)
|
|
240
240
|
|
|
241
241
|
return {
|
|
@@ -254,7 +254,12 @@ const oidc = {
|
|
|
254
254
|
},
|
|
255
255
|
}
|
|
256
256
|
},
|
|
257
|
-
corpPass: async (
|
|
257
|
+
corpPass: async (
|
|
258
|
+
{ nric, uuid, name, isSingPassHolder },
|
|
259
|
+
iss,
|
|
260
|
+
aud,
|
|
261
|
+
nonce,
|
|
262
|
+
) => {
|
|
258
263
|
const baseClaims = {
|
|
259
264
|
iat: Math.floor(Date.now() / 1000),
|
|
260
265
|
exp: Math.floor(Date.now() / 1000) + 24 * 60 * 60,
|
|
@@ -262,8 +267,7 @@ const oidc = {
|
|
|
262
267
|
aud,
|
|
263
268
|
}
|
|
264
269
|
|
|
265
|
-
const
|
|
266
|
-
const sub = `s=${profile.nric},u=${uuid},c=SG`
|
|
270
|
+
const sub = `s=${nric},u=${uuid},c=SG`
|
|
267
271
|
|
|
268
272
|
const accessTokenClaims = {
|
|
269
273
|
...baseClaims,
|
|
@@ -284,20 +288,23 @@ const oidc = {
|
|
|
284
288
|
|
|
285
289
|
const accessTokenHash = hashToken(accessToken)
|
|
286
290
|
|
|
291
|
+
const refreshToken = crypto.randomBytes(20).toString('hex')
|
|
292
|
+
const refreshTokenHash = hashToken(refreshToken)
|
|
293
|
+
|
|
287
294
|
return {
|
|
288
|
-
refreshToken: 'refresh',
|
|
289
295
|
accessToken,
|
|
296
|
+
refreshToken,
|
|
290
297
|
idTokenClaims: {
|
|
291
298
|
...baseClaims,
|
|
292
|
-
rt_hash:
|
|
299
|
+
rt_hash: refreshTokenHash,
|
|
293
300
|
at_hash: accessTokenHash,
|
|
294
301
|
amr: ['pwd'],
|
|
295
302
|
sub,
|
|
296
303
|
...(nonce ? { nonce } : {}),
|
|
297
304
|
userInfo: {
|
|
298
305
|
CPAccType: 'User',
|
|
299
|
-
CPUID_FullName:
|
|
300
|
-
ISSPHOLDER:
|
|
306
|
+
CPUID_FullName: name,
|
|
307
|
+
ISSPHOLDER: isSingPassHolder ? 'YES' : 'NO',
|
|
301
308
|
},
|
|
302
309
|
},
|
|
303
310
|
}
|
|
@@ -305,15 +312,8 @@ const oidc = {
|
|
|
305
312
|
},
|
|
306
313
|
}
|
|
307
314
|
|
|
308
|
-
const singPassNric = process.env.MOCKPASS_NRIC || saml.singPass[0]
|
|
309
|
-
const corpPassNric = process.env.MOCKPASS_NRIC || saml.corpPass[0].nric
|
|
310
|
-
const uen = process.env.MOCKPASS_UEN || saml.corpPass[0].uen
|
|
311
|
-
|
|
312
315
|
module.exports = {
|
|
313
316
|
saml,
|
|
314
317
|
oidc,
|
|
315
318
|
myinfo,
|
|
316
|
-
singPassNric,
|
|
317
|
-
corpPassNric,
|
|
318
|
-
uen,
|
|
319
319
|
}
|
package/lib/auth-code.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const ExpiryMap = require('expiry-map')
|
|
2
|
+
const crypto = require('crypto')
|
|
3
|
+
|
|
4
|
+
const AUTH_CODE_TIMEOUT = 5 * 60 * 1000
|
|
5
|
+
const profileAndNonceStore = new ExpiryMap(AUTH_CODE_TIMEOUT)
|
|
6
|
+
|
|
7
|
+
const generateAuthCode = ({ profile, nonce }) => {
|
|
8
|
+
const authCode = crypto.randomBytes(45).toString('base64')
|
|
9
|
+
profileAndNonceStore.set(authCode, { profile, nonce })
|
|
10
|
+
return authCode
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const lookUpByAuthCode = (authCode) => {
|
|
14
|
+
return profileAndNonceStore.get(authCode)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
module.exports = { generateAuthCode, lookUpByAuthCode }
|
|
@@ -8,6 +8,8 @@ const qs = require('querystring')
|
|
|
8
8
|
const { v1: uuid } = require('uuid')
|
|
9
9
|
|
|
10
10
|
const assertions = require('../../assertions')
|
|
11
|
+
const { lookUpByAuthCode } = require('../../auth-code')
|
|
12
|
+
const { lookUpBySamlArtifact } = require('../../saml-artifact')
|
|
11
13
|
|
|
12
14
|
const MYINFO_ASSERT_ENDPOINT = '/consent/myinfo-com'
|
|
13
15
|
const AUTHORIZE_ENDPOINT = '/consent/oauth2/authorize'
|
|
@@ -47,41 +49,33 @@ const authorizeViaSAML = authorize(
|
|
|
47
49
|
)
|
|
48
50
|
|
|
49
51
|
const authorizeViaOIDC = authorize(
|
|
50
|
-
(
|
|
51
|
-
`/singpass/authorize?client_id=MYINFO-CONSENTPLATFORM&redirect_uri=${MYINFO_ASSERT_ENDPOINT}&state=${
|
|
52
|
+
(state) =>
|
|
53
|
+
`/singpass/authorize?client_id=MYINFO-CONSENTPLATFORM&redirect_uri=${MYINFO_ASSERT_ENDPOINT}&state=${state}`,
|
|
52
54
|
)
|
|
53
55
|
|
|
54
56
|
function config(app) {
|
|
55
57
|
app.get(MYINFO_ASSERT_ENDPOINT, (req, res) => {
|
|
56
58
|
const rawArtifact = req.query.SAMLart || req.query.code
|
|
57
59
|
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
60
|
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
61
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
62
|
+
let profile, myinfoVersion
|
|
63
|
+
if (req.query.code) {
|
|
64
|
+
profile = lookUpByAuthCode(artifact).profile
|
|
65
|
+
myinfoVersion = 'v3'
|
|
66
|
+
} else {
|
|
67
|
+
profile = lookUpBySamlArtifact(artifact)
|
|
68
|
+
myinfoVersion = 'v2'
|
|
76
69
|
}
|
|
77
|
-
const
|
|
70
|
+
const { nric: id } = profile
|
|
71
|
+
|
|
72
|
+
const persona = assertions.myinfo[myinfoVersion].personas[id]
|
|
78
73
|
if (!persona) {
|
|
79
74
|
res.status(404).send({
|
|
80
75
|
message: 'Cannot find MyInfo Persona',
|
|
81
76
|
artifact,
|
|
82
|
-
|
|
77
|
+
myinfoVersion,
|
|
83
78
|
id,
|
|
84
|
-
persona,
|
|
85
79
|
})
|
|
86
80
|
} else {
|
|
87
81
|
res.cookie('connect.sid', id)
|
package/lib/express/oidc.js
CHANGED
|
@@ -6,51 +6,85 @@ const path = require('path')
|
|
|
6
6
|
const ExpiryMap = require('expiry-map')
|
|
7
7
|
|
|
8
8
|
const assertions = require('../assertions')
|
|
9
|
-
const {
|
|
9
|
+
const { generateAuthCode, lookUpByAuthCode } = require('../auth-code')
|
|
10
10
|
|
|
11
11
|
const LOGIN_TEMPLATE = fs.readFileSync(
|
|
12
12
|
path.resolve(__dirname, '../../static/html/login-page.html'),
|
|
13
13
|
'utf8',
|
|
14
14
|
)
|
|
15
|
-
const
|
|
16
|
-
const
|
|
15
|
+
const REFRESH_TOKEN_TIMEOUT = 24 * 60 * 60 * 1000
|
|
16
|
+
const profileStore = new ExpiryMap(REFRESH_TOKEN_TIMEOUT)
|
|
17
17
|
|
|
18
18
|
const signingPem = fs.readFileSync(
|
|
19
19
|
path.resolve(__dirname, '../../static/certs/spcp-key.pem'),
|
|
20
20
|
)
|
|
21
21
|
|
|
22
|
+
const buildAssertURL = (redirectURI, authCode, state) =>
|
|
23
|
+
`${redirectURI}?code=${encodeURIComponent(
|
|
24
|
+
authCode,
|
|
25
|
+
)}&state=${encodeURIComponent(state)}`
|
|
26
|
+
|
|
22
27
|
const idGenerator = {
|
|
23
|
-
singPass: (
|
|
24
|
-
assertions.myinfo.v3.personas[
|
|
25
|
-
corpPass: (
|
|
28
|
+
singPass: ({ nric }) =>
|
|
29
|
+
assertions.myinfo.v3.personas[nric] ? `${nric} [MyInfo]` : nric,
|
|
30
|
+
corpPass: ({ nric, uen }) => `${nric} / UEN: ${uen}`,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const customProfileFromHeaders = {
|
|
34
|
+
singPass: (req) => {
|
|
35
|
+
const customNricHeader = req.header('X-Custom-NRIC')
|
|
36
|
+
const customUuidHeader = req.header('X-Custom-UUID')
|
|
37
|
+
if (!customNricHeader || !customUuidHeader) {
|
|
38
|
+
return false
|
|
39
|
+
}
|
|
40
|
+
return { nric: customNricHeader, uuid: customUuidHeader }
|
|
41
|
+
},
|
|
42
|
+
corpPass: (req) => {
|
|
43
|
+
const customNricHeader = req.header('X-Custom-NRIC')
|
|
44
|
+
const customUuidHeader = req.header('X-Custom-UUID')
|
|
45
|
+
const customUenHeader = req.header('X-Custom-UEN')
|
|
46
|
+
if (!customNricHeader || !customUuidHeader || !customUenHeader) {
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
nric: customNricHeader,
|
|
51
|
+
uuid: customUuidHeader,
|
|
52
|
+
uen: customUenHeader,
|
|
53
|
+
}
|
|
54
|
+
},
|
|
26
55
|
}
|
|
27
56
|
|
|
28
|
-
function config(app, { showLoginPage,
|
|
57
|
+
function config(app, { showLoginPage, serviceProvider }) {
|
|
29
58
|
for (const idp of ['singPass', 'corpPass']) {
|
|
59
|
+
const profiles = assertions.oidc[idp]
|
|
60
|
+
const defaultProfile =
|
|
61
|
+
profiles.find((p) => p.nric === process.env.MOCKPASS_NRIC) || profiles[0]
|
|
62
|
+
|
|
30
63
|
app.get(`/${idp.toLowerCase()}/authorize`, (req, res) => {
|
|
31
|
-
const redirectURI = req.query
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
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)
|
|
64
|
+
const { redirect_uri: redirectURI, state, nonce } = req.query
|
|
65
|
+
if (showLoginPage(req)) {
|
|
66
|
+
const values = profiles.map((profile) => {
|
|
67
|
+
const authCode = generateAuthCode({ profile, nonce })
|
|
68
|
+
const assertURL = buildAssertURL(redirectURI, authCode, state)
|
|
69
|
+
const id = idGenerator[idp](profile)
|
|
44
70
|
return { id, assertURL }
|
|
45
71
|
})
|
|
46
|
-
const response = render(LOGIN_TEMPLATE, {
|
|
72
|
+
const response = render(LOGIN_TEMPLATE, {
|
|
73
|
+
values,
|
|
74
|
+
customProfileConfig: {
|
|
75
|
+
endpoint: `/${idp.toLowerCase()}/authorize/custom-profile`,
|
|
76
|
+
showUuid: true,
|
|
77
|
+
showUen: idp === 'corpPass',
|
|
78
|
+
redirectURI,
|
|
79
|
+
state,
|
|
80
|
+
nonce,
|
|
81
|
+
},
|
|
82
|
+
})
|
|
47
83
|
res.send(response)
|
|
48
84
|
} else {
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
const assertURL = `${redirectURI}?code=${code}&state=${state}`
|
|
85
|
+
const profile = customProfileFromHeaders[idp](req) || defaultProfile
|
|
86
|
+
const authCode = generateAuthCode({ profile, nonce })
|
|
87
|
+
const assertURL = buildAssertURL(redirectURI, authCode, state)
|
|
54
88
|
console.warn(
|
|
55
89
|
`Redirecting login from ${req.query.client_id} to ${redirectURI}`,
|
|
56
90
|
)
|
|
@@ -58,45 +92,47 @@ function config(app, { showLoginPage, idpConfig, serviceProvider }) {
|
|
|
58
92
|
}
|
|
59
93
|
})
|
|
60
94
|
|
|
95
|
+
app.get(`/${idp.toLowerCase()}/authorize/custom-profile`, (req, res) => {
|
|
96
|
+
const { nric, uuid, uen, redirectURI, state, nonce } = req.query
|
|
97
|
+
|
|
98
|
+
const profile = { nric, uuid }
|
|
99
|
+
if (idp === 'corpPass') {
|
|
100
|
+
profile.name = `Name of ${nric}`
|
|
101
|
+
profile.isSingPassHolder = false
|
|
102
|
+
profile.uen = uen
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const authCode = generateAuthCode({ profile, nonce })
|
|
106
|
+
const assertURL = buildAssertURL(redirectURI, authCode, state)
|
|
107
|
+
res.redirect(assertURL)
|
|
108
|
+
})
|
|
109
|
+
|
|
61
110
|
app.post(
|
|
62
111
|
`/${idp.toLowerCase()}/token`,
|
|
63
112
|
express.urlencoded({ extended: false }),
|
|
64
113
|
async (req, res) => {
|
|
65
114
|
const { client_id: aud, grant_type: grant } = req.body
|
|
66
|
-
let
|
|
115
|
+
let profile, nonce
|
|
67
116
|
|
|
68
117
|
if (grant === 'refresh_token') {
|
|
69
|
-
const { refresh_token:
|
|
70
|
-
console.warn(`Refreshing tokens with ${
|
|
118
|
+
const { refresh_token: suppliedRefreshToken } = req.body
|
|
119
|
+
console.warn(`Refreshing tokens with ${suppliedRefreshToken}`)
|
|
71
120
|
|
|
72
|
-
|
|
121
|
+
profile = profileStore.get(suppliedRefreshToken)
|
|
73
122
|
} else {
|
|
74
|
-
const { code:
|
|
123
|
+
const { code: authCode } = req.body
|
|
75
124
|
console.warn(
|
|
76
|
-
`Received
|
|
125
|
+
`Received auth code ${authCode} from ${aud} and ${req.body.redirect_uri}`,
|
|
77
126
|
)
|
|
78
|
-
|
|
79
|
-
uuid = artifactBuffer.readInt8(artifactBuffer.length - 1)
|
|
80
|
-
nonce = nonceStore.get(encodeURIComponent(artifact))
|
|
127
|
+
;({ profile, nonce } = lookUpByAuthCode(authCode))
|
|
81
128
|
}
|
|
82
129
|
|
|
83
|
-
|
|
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
|
-
}
|
|
130
|
+
const iss = `${req.protocol}://${req.get('host')}`
|
|
92
131
|
|
|
93
132
|
const { idTokenClaims, accessToken, refreshToken } =
|
|
94
|
-
await assertions.oidc.create[idp](
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
aud,
|
|
98
|
-
nonce,
|
|
99
|
-
)
|
|
133
|
+
await assertions.oidc.create[idp](profile, iss, aud, nonce)
|
|
134
|
+
|
|
135
|
+
profileStore.set(refreshToken, profile)
|
|
100
136
|
|
|
101
137
|
const signingKey = await jose.JWK.asKey(signingPem, 'pem')
|
|
102
138
|
const signedIdToken = await jose.JWS.createSign(
|
package/lib/express/saml.js
CHANGED
|
@@ -8,7 +8,10 @@ const moment = require('moment')
|
|
|
8
8
|
|
|
9
9
|
const assertions = require('../assertions')
|
|
10
10
|
const crypto = require('../crypto')
|
|
11
|
-
const {
|
|
11
|
+
const {
|
|
12
|
+
generateSamlArtifact,
|
|
13
|
+
lookUpBySamlArtifact,
|
|
14
|
+
} = require('../saml-artifact')
|
|
12
15
|
|
|
13
16
|
const domParser = new DOMParser()
|
|
14
17
|
const dom = (xmlString) => domParser.parseFromString(xmlString)
|
|
@@ -24,10 +27,36 @@ const LOGIN_TEMPLATE = fs.readFileSync(
|
|
|
24
27
|
|
|
25
28
|
const MYINFO_ASSERT_ENDPOINT = '/consent/myinfo-com'
|
|
26
29
|
|
|
30
|
+
const buildAssertURL = (assertEndpoint, samlArt, relayState) => {
|
|
31
|
+
let assertURL = `${assertEndpoint}?SAMLart=${encodeURIComponent(samlArt)}`
|
|
32
|
+
if (relayState !== undefined) {
|
|
33
|
+
assertURL += `&RelayState=${encodeURIComponent(relayState)}`
|
|
34
|
+
}
|
|
35
|
+
return assertURL
|
|
36
|
+
}
|
|
37
|
+
|
|
27
38
|
const idGenerator = {
|
|
28
|
-
singPass: (
|
|
29
|
-
assertions.myinfo.v2.personas[
|
|
30
|
-
corpPass: (
|
|
39
|
+
singPass: ({ nric }) =>
|
|
40
|
+
assertions.myinfo.v2.personas[nric] ? `${nric} [MyInfo]` : nric,
|
|
41
|
+
corpPass: ({ nric, uen }) => `${nric} / UEN: ${uen}`,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const customProfileFromHeaders = {
|
|
45
|
+
singPass: (req) => {
|
|
46
|
+
const customNricHeader = req.header('X-Custom-NRIC')
|
|
47
|
+
if (!customNricHeader) {
|
|
48
|
+
return false
|
|
49
|
+
}
|
|
50
|
+
return { nric: customNricHeader }
|
|
51
|
+
},
|
|
52
|
+
corpPass: (req) => {
|
|
53
|
+
const customNricHeader = req.header('X-Custom-NRIC')
|
|
54
|
+
const customUenHeader = req.header('X-Custom-UEN')
|
|
55
|
+
if (!customNricHeader || !customUenHeader) {
|
|
56
|
+
return false
|
|
57
|
+
}
|
|
58
|
+
return { nric: customNricHeader, uen: customUenHeader }
|
|
59
|
+
},
|
|
31
60
|
}
|
|
32
61
|
|
|
33
62
|
function config(
|
|
@@ -38,38 +67,41 @@ function config(
|
|
|
38
67
|
crypto(serviceProvider)
|
|
39
68
|
|
|
40
69
|
for (const idp of ['singPass', 'corpPass']) {
|
|
70
|
+
const partnerId = idpConfig[idp].id
|
|
71
|
+
const partnerAssertEndpoint = idpConfig[idp].assertEndpoint
|
|
72
|
+
|
|
73
|
+
const profiles = assertions.saml[idp]
|
|
74
|
+
const defaultProfile =
|
|
75
|
+
profiles.find((p) => p.nric === process.env.MOCKPASS_NRIC) || profiles[0]
|
|
76
|
+
|
|
41
77
|
app.get(`/${idp.toLowerCase()}/logininitial`, (req, res) => {
|
|
42
78
|
const assertEndpoint =
|
|
43
79
|
req.query.esrvcID === 'MYINFO-CONSENTPLATFORM' && idp === 'singPass'
|
|
44
80
|
? MYINFO_ASSERT_ENDPOINT
|
|
45
|
-
:
|
|
81
|
+
: partnerAssertEndpoint || req.query.PartnerId
|
|
46
82
|
const relayState = req.query.Target
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
let assertURL = `${assertEndpoint}?SAMLart=${samlArt}`
|
|
53
|
-
if (relayState !== undefined) {
|
|
54
|
-
assertURL += `&RelayState=${encodeURIComponent(relayState)}`
|
|
55
|
-
}
|
|
56
|
-
const id = idGenerator[idp](rawId)
|
|
83
|
+
if (showLoginPage(req)) {
|
|
84
|
+
const values = profiles.map((profile) => {
|
|
85
|
+
const samlArt = generateSamlArtifact(partnerId, profile)
|
|
86
|
+
const assertURL = buildAssertURL(assertEndpoint, samlArt, relayState)
|
|
87
|
+
const id = idGenerator[idp](profile)
|
|
57
88
|
return { id, assertURL }
|
|
58
89
|
})
|
|
59
|
-
const hashedPartnerId = hashPartnerId(partnerId)
|
|
60
90
|
const response = render(LOGIN_TEMPLATE, {
|
|
61
91
|
values,
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
92
|
+
customProfileConfig: {
|
|
93
|
+
endpoint: `/${idp.toLowerCase()}/logininitial/custom-profile`,
|
|
94
|
+
showUuid: false,
|
|
95
|
+
showUen: idp === 'corpPass',
|
|
96
|
+
assertEndpoint,
|
|
97
|
+
relayState,
|
|
98
|
+
},
|
|
65
99
|
})
|
|
66
100
|
res.send(response)
|
|
67
101
|
} else {
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
assertURL += `&RelayState=${encodeURIComponent(relayState)}`
|
|
72
|
-
}
|
|
102
|
+
const profile = customProfileFromHeaders[idp](req) || defaultProfile
|
|
103
|
+
const samlArt = generateSamlArtifact(partnerId, profile)
|
|
104
|
+
const assertURL = buildAssertURL(assertEndpoint, samlArt, relayState)
|
|
73
105
|
console.warn(
|
|
74
106
|
`Redirecting login from ${req.query.PartnerId} to ${assertURL}`,
|
|
75
107
|
)
|
|
@@ -77,6 +109,19 @@ function config(
|
|
|
77
109
|
}
|
|
78
110
|
})
|
|
79
111
|
|
|
112
|
+
app.get(`/${idp.toLowerCase()}/logininitial/custom-profile`, (req, res) => {
|
|
113
|
+
const { nric, uen, assertEndpoint, relayState } = req.query
|
|
114
|
+
|
|
115
|
+
const profile = { nric }
|
|
116
|
+
if (idp === 'corpPass') {
|
|
117
|
+
profile.uen = uen
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const samlArt = generateSamlArtifact(partnerId, profile)
|
|
121
|
+
const assertURL = buildAssertURL(assertEndpoint, samlArt, relayState)
|
|
122
|
+
res.redirect(assertURL)
|
|
123
|
+
})
|
|
124
|
+
|
|
80
125
|
app.post(
|
|
81
126
|
`/${idp.toLowerCase()}/soap`,
|
|
82
127
|
express.text({ type: 'text/xml' }),
|
|
@@ -102,28 +147,7 @@ function config(
|
|
|
102
147
|
// Handle encoded base64 Artifact
|
|
103
148
|
// Take the template and plug in the typical SingPass/CorpPass response
|
|
104
149
|
// Sign and encrypt the assertion
|
|
105
|
-
const
|
|
106
|
-
const samlArtifactMessage = samlArtifactBuffer.toString('utf8', 24)
|
|
107
|
-
|
|
108
|
-
let nric
|
|
109
|
-
if (samlArtifactMessage.startsWith('customNric:')) {
|
|
110
|
-
nric = samlArtifactMessage.slice('customNric:'.length)
|
|
111
|
-
} else {
|
|
112
|
-
let index = samlArtifactBuffer.readInt8(
|
|
113
|
-
samlArtifactBuffer.length - 1,
|
|
114
|
-
)
|
|
115
|
-
// use env NRIC when SHOW_LOGIN_PAGE is false
|
|
116
|
-
if (index === -1) {
|
|
117
|
-
index =
|
|
118
|
-
idp === 'singPass'
|
|
119
|
-
? assertions.saml.singPass.indexOf(assertions.singPassNric)
|
|
120
|
-
: assertions.saml.corpPass.findIndex(
|
|
121
|
-
(c) => c.nric === assertions.corpPassNric,
|
|
122
|
-
)
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
nric = assertions.saml[idp][index]
|
|
126
|
-
}
|
|
150
|
+
const profile = lookUpBySamlArtifact(samlArtifact)
|
|
127
151
|
|
|
128
152
|
const samlArtifactResolveId = xpath.select(
|
|
129
153
|
"string(//*[local-name(.)='ArtifactResolve']/@ID)",
|
|
@@ -131,9 +155,9 @@ function config(
|
|
|
131
155
|
)
|
|
132
156
|
|
|
133
157
|
let result = assertions.saml.create[idp](
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
158
|
+
profile,
|
|
159
|
+
partnerId,
|
|
160
|
+
partnerAssertEndpoint,
|
|
137
161
|
samlArtifactResolveId,
|
|
138
162
|
)
|
|
139
163
|
|
|
@@ -148,8 +172,8 @@ function config(
|
|
|
148
172
|
let response = render(TEMPLATE, {
|
|
149
173
|
assertion,
|
|
150
174
|
issueInstant: moment.utc().format(),
|
|
151
|
-
issuer:
|
|
152
|
-
destination:
|
|
175
|
+
issuer: partnerId,
|
|
176
|
+
destination: partnerAssertEndpoint,
|
|
153
177
|
inResponseTo: samlArtifactResolveId,
|
|
154
178
|
})
|
|
155
179
|
if (cryptoConfig.signResponse) {
|
package/lib/express/sgid.js
CHANGED
|
@@ -3,17 +3,14 @@ const fs = require('fs')
|
|
|
3
3
|
const { render } = require('mustache')
|
|
4
4
|
const jose = require('node-jose')
|
|
5
5
|
const path = require('path')
|
|
6
|
-
const ExpiryMap = require('expiry-map')
|
|
7
6
|
|
|
8
7
|
const assertions = require('../assertions')
|
|
9
|
-
const {
|
|
8
|
+
const { generateAuthCode, lookUpByAuthCode } = require('../auth-code')
|
|
10
9
|
|
|
11
10
|
const LOGIN_TEMPLATE = fs.readFileSync(
|
|
12
11
|
path.resolve(__dirname, '../../static/html/login-page.html'),
|
|
13
12
|
'utf8',
|
|
14
13
|
)
|
|
15
|
-
const NONCE_TIMEOUT = 5 * 60 * 1000
|
|
16
|
-
const nonceStore = new ExpiryMap(NONCE_TIMEOUT)
|
|
17
14
|
|
|
18
15
|
const PATH_PREFIX = '/sgid/v1/oauth'
|
|
19
16
|
|
|
@@ -22,38 +19,37 @@ const signingPem = fs.readFileSync(
|
|
|
22
19
|
)
|
|
23
20
|
|
|
24
21
|
const idGenerator = {
|
|
25
|
-
singPass: (
|
|
26
|
-
assertions.myinfo.v3.personas[
|
|
22
|
+
singPass: ({ nric }) =>
|
|
23
|
+
assertions.myinfo.v3.personas[nric] ? `${nric} [MyInfo]` : nric,
|
|
27
24
|
}
|
|
28
25
|
|
|
29
|
-
|
|
26
|
+
const buildAssertURL = (redirectURI, authCode, state) =>
|
|
27
|
+
`${redirectURI}?code=${encodeURIComponent(
|
|
28
|
+
authCode,
|
|
29
|
+
)}&state=${encodeURIComponent(state)}`
|
|
30
|
+
|
|
31
|
+
function config(app, { showLoginPage, serviceProvider }) {
|
|
32
|
+
const profiles = assertions.oidc.singPass
|
|
33
|
+
const defaultProfile =
|
|
34
|
+
profiles.find((p) => p.nric === process.env.MOCKPASS_NRIC) || profiles[0]
|
|
35
|
+
|
|
30
36
|
app.get(`${PATH_PREFIX}/authorize`, (req, res) => {
|
|
31
|
-
const redirectURI = req.query
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
const code = encodeURIComponent(
|
|
40
|
-
samlArtifact(idpConfig.singPass.id, index),
|
|
41
|
-
)
|
|
42
|
-
if (req.query.nonce) {
|
|
43
|
-
nonceStore.set(code, req.query.nonce)
|
|
44
|
-
}
|
|
45
|
-
const assertURL = `${redirectURI}?code=${code}&state=${state}`
|
|
46
|
-
const id = idGenerator.singPass(rawId)
|
|
37
|
+
const { redirect_uri: redirectURI, state, nonce } = req.query
|
|
38
|
+
if (showLoginPage(req)) {
|
|
39
|
+
const values = profiles
|
|
40
|
+
.filter((profile) => assertions.myinfo.v3.personas[profile.nric])
|
|
41
|
+
.map((profile) => {
|
|
42
|
+
const authCode = generateAuthCode({ profile, nonce })
|
|
43
|
+
const assertURL = buildAssertURL(redirectURI, authCode, state)
|
|
44
|
+
const id = idGenerator.singPass(profile)
|
|
47
45
|
return { id, assertURL }
|
|
48
46
|
})
|
|
49
47
|
const response = render(LOGIN_TEMPLATE, { values })
|
|
50
48
|
res.send(response)
|
|
51
49
|
} else {
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
const assertURL = `${redirectURI}?code=${code}&state=${state}`
|
|
50
|
+
const profile = defaultProfile
|
|
51
|
+
const authCode = generateAuthCode({ profile, nonce })
|
|
52
|
+
const assertURL = buildAssertURL(redirectURI, authCode, state)
|
|
57
53
|
console.warn(
|
|
58
54
|
`Redirecting login from ${req.query.client_id} to ${assertURL}`,
|
|
59
55
|
)
|
|
@@ -67,31 +63,24 @@ function config(app, { showLoginPage, idpConfig, serviceProvider }) {
|
|
|
67
63
|
express.urlencoded({ extended: true }),
|
|
68
64
|
async (req, res) => {
|
|
69
65
|
console.log(req.body)
|
|
70
|
-
const { client_id: aud, code:
|
|
71
|
-
let uuid
|
|
66
|
+
const { client_id: aud, code: authCode } = req.body
|
|
72
67
|
|
|
73
68
|
console.warn(
|
|
74
|
-
`Received
|
|
69
|
+
`Received auth code ${authCode} from ${aud} and ${req.body.redirect_uri}`,
|
|
75
70
|
)
|
|
76
71
|
try {
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
uuid,
|
|
90
|
-
`${req.protocol}://${req.get('host')}`,
|
|
91
|
-
aud,
|
|
92
|
-
nonce,
|
|
93
|
-
accessToken,
|
|
94
|
-
)
|
|
72
|
+
const { profile, nonce } = lookUpByAuthCode(authCode)
|
|
73
|
+
|
|
74
|
+
const accessToken = profile.uuid
|
|
75
|
+
const iss = `${req.protocol}://${req.get('host')}`
|
|
76
|
+
|
|
77
|
+
const { idTokenClaims, refreshToken } = assertions.oidc.create.singPass(
|
|
78
|
+
profile,
|
|
79
|
+
iss,
|
|
80
|
+
aud,
|
|
81
|
+
nonce,
|
|
82
|
+
accessToken,
|
|
83
|
+
)
|
|
95
84
|
// Change sub from `s=${nric},u=${uuid}`
|
|
96
85
|
// to `u=${uuid}` to be consistent with userinfo sub
|
|
97
86
|
idTokenClaims.sub = idTokenClaims.sub.split(',')[1]
|
|
@@ -123,7 +112,7 @@ function config(app, { showLoginPage, idpConfig, serviceProvider }) {
|
|
|
123
112
|
const uuid = (
|
|
124
113
|
req.headers.authorization || req.headers.Authorization
|
|
125
114
|
).replace('Bearer ', '')
|
|
126
|
-
const nric = assertions.oidc.singPass
|
|
115
|
+
const nric = assertions.oidc.singPass.find((p) => p.uuid === uuid).nric
|
|
127
116
|
const persona = assertions.myinfo.v3.personas[nric]
|
|
128
117
|
const name = persona.name.value
|
|
129
118
|
const dateOfBirth = persona.dob.value
|
package/lib/saml-artifact.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
const ExpiryMap = require('expiry-map')
|
|
1
2
|
const crypto = require('crypto')
|
|
2
3
|
|
|
4
|
+
const SAML_ARTIFACT_TIMEOUT = 5 * 60 * 1000
|
|
5
|
+
const profileStore = new ExpiryMap(SAML_ARTIFACT_TIMEOUT)
|
|
6
|
+
|
|
3
7
|
/**
|
|
4
8
|
* Construct a SingPass/CorpPass SAML artifact, a base64
|
|
5
9
|
* encoding of a byte sequence consisting of the following:
|
|
@@ -8,25 +12,28 @@ const crypto = require('crypto')
|
|
|
8
12
|
* - a 20-byte sha1 hash of the partner id, and;
|
|
9
13
|
* - a 20-byte random sequence that is effectively the message id
|
|
10
14
|
* @param {string} partnerId - the partner id
|
|
11
|
-
* @param {
|
|
15
|
+
* @param {string} profile - the profile (identity) to store
|
|
12
16
|
* @return {string} the SAML artifact, a base64 string
|
|
13
17
|
* containing the type code, the endpoint index,
|
|
14
|
-
* the hash of the partner id, followed by 20 random bytes
|
|
18
|
+
* the hash of the partner id, followed by 20 random bytes;
|
|
19
|
+
* this can be used to look up the stored profile (identity)
|
|
15
20
|
*/
|
|
16
|
-
function
|
|
17
|
-
const hashedPartnerId =
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
`00040000${hashedPartnerId}${randomBytes}
|
|
21
|
+
function generateSamlArtifact(partnerId, profile) {
|
|
22
|
+
const hashedPartnerId = crypto
|
|
23
|
+
.createHash('sha1')
|
|
24
|
+
.update(partnerId, 'utf8')
|
|
25
|
+
.digest('hex')
|
|
26
|
+
const randomBytes = crypto.randomBytes(20).toString('hex')
|
|
27
|
+
const samlArtifact = Buffer.from(
|
|
28
|
+
`00040000${hashedPartnerId}${randomBytes}`,
|
|
24
29
|
'hex',
|
|
25
30
|
).toString('base64')
|
|
31
|
+
profileStore.set(samlArtifact, profile)
|
|
32
|
+
return samlArtifact
|
|
26
33
|
}
|
|
27
34
|
|
|
28
|
-
function
|
|
29
|
-
return
|
|
35
|
+
function lookUpBySamlArtifact(samlArtifact) {
|
|
36
|
+
return profileStore.get(samlArtifact)
|
|
30
37
|
}
|
|
31
38
|
|
|
32
|
-
module.exports = {
|
|
39
|
+
module.exports = { generateSamlArtifact, lookUpBySamlArtifact }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengovsg/mockpass",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.9.0",
|
|
4
4
|
"description": "A mock SingPass/CorpPass server for dev purposes",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -39,8 +39,8 @@
|
|
|
39
39
|
"@xmldom/xmldom": "^0.8.0",
|
|
40
40
|
"base-64": "^1.0.0",
|
|
41
41
|
"cookie-parser": "^1.4.3",
|
|
42
|
-
"dotenv": "^
|
|
43
|
-
"expiry-map": "^
|
|
42
|
+
"dotenv": "^16.0.0",
|
|
43
|
+
"expiry-map": "^2.0.0",
|
|
44
44
|
"express": "^4.16.3",
|
|
45
45
|
"jsonwebtoken": "^8.4.0",
|
|
46
46
|
"lodash": "^4.17.11",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"husky": "^7.0.0",
|
|
66
66
|
"lint-staged": "^12.0.2",
|
|
67
67
|
"nodemon": "^2.0.4",
|
|
68
|
-
"pinst": "^
|
|
68
|
+
"pinst": "^3.0.0",
|
|
69
69
|
"prettier": "^2.0.5"
|
|
70
70
|
},
|
|
71
71
|
"lint-staged": {
|
|
@@ -768,31 +768,6 @@ function invalidLoginAction(errorMessage, captchaVal) {
|
|
|
768
768
|
}
|
|
769
769
|
}
|
|
770
770
|
|
|
771
|
-
function hexEncode(str) {
|
|
772
|
-
var result = '';
|
|
773
|
-
for (var i = 0; i < str.length; i++) {
|
|
774
|
-
result += str.charCodeAt(i).toString(16);
|
|
775
|
-
}
|
|
776
|
-
return result;
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
function hexToBase64(hexString) {
|
|
780
|
-
return btoa(hexString.match(/\w{2}/g).map(function(a) {
|
|
781
|
-
return String.fromCharCode(parseInt(a, 16));
|
|
782
|
-
}).join(''));
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
function generateSamlArtFromCustomNric() {
|
|
786
|
-
var customNric = document.getElementById('customNric').value;
|
|
787
|
-
if (customNric.length !== 9) {
|
|
788
|
-
return false;
|
|
789
|
-
}
|
|
790
|
-
var hashedPartnerId = document.getElementById('hashedPartnerId').value;
|
|
791
|
-
var artifactDataHex = '00040000' + hashedPartnerId + hexEncode('customNric:' + customNric);
|
|
792
|
-
document.getElementById('customNricSamlArt').value = hexToBase64(artifactDataHex);
|
|
793
|
-
return true;
|
|
794
|
-
}
|
|
795
|
-
|
|
796
771
|
/*******************************************************************************
|
|
797
772
|
* WOGAA RELATED METHODS STARTS
|
|
798
773
|
******************************************************************************/
|
|
@@ -176,33 +176,48 @@
|
|
|
176
176
|
</div>
|
|
177
177
|
</div>
|
|
178
178
|
</div>
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
</div>
|
|
179
|
+
<div>
|
|
180
|
+
<input type="hidden" name="CSRFToken" value="null" />
|
|
181
|
+
</div>
|
|
183
182
|
</form>
|
|
184
|
-
|
|
183
|
+
{{#customProfileConfig}}
|
|
184
|
+
<form action="{{endpoint}}" class="innate-form" method="get">
|
|
185
|
+
<br>
|
|
186
|
+
<h6>or with your own user</h6>
|
|
187
|
+
<br>
|
|
188
|
+
|
|
189
|
+
<label for="nric">NRIC</label>
|
|
190
|
+
<input type="text" name="nric" id="nric" value="S1234567A" minlength="9" maxlength="9">
|
|
191
|
+
|
|
192
|
+
{{#showUuid}}
|
|
193
|
+
<label for="uuid">UUID</label>
|
|
194
|
+
<input type="text" name="uuid" id="uuid" value="ef39a074-b64d-4990-a937-6f80772e2bb8" minlength="36" maxlength="36">
|
|
195
|
+
{{/showUuid}}
|
|
196
|
+
|
|
197
|
+
{{#showUen}}
|
|
198
|
+
<label for="uen">UEN</label>
|
|
199
|
+
<input type="text" name="uen" id="uen" value="123456789D" minlength="9" maxlength="10">
|
|
200
|
+
{{/showUen}}
|
|
201
|
+
|
|
202
|
+
{{#assertEndpoint}}<input type="hidden" name="assertEndpoint" value="{{ assertEndpoint }}" />{{/assertEndpoint}}
|
|
203
|
+
{{#redirectURI}}<input type="hidden" name="redirectURI" value="{{ redirectURI }}" />{{/redirectURI}}
|
|
204
|
+
{{#relayState}}<input type="hidden" name="relayState" value="{{ relayState }}" />{{/relayState}}
|
|
205
|
+
{{#state}}<input type="hidden" name="state" value="{{ state }}" />{{/state}}
|
|
206
|
+
{{#nonce}}<input type="hidden" name="nonce" value="{{ nonce }}" />{{/nonce}}
|
|
207
|
+
|
|
208
|
+
<button autofocus="" type="submit">Login</button>
|
|
185
209
|
<br>
|
|
186
|
-
{{#assertEndpoint}}
|
|
187
|
-
<h6>or with your own user</h6>
|
|
188
|
-
<br>
|
|
189
|
-
<input type="hidden" name="RelayState" value="{{ relayState }}" />
|
|
190
|
-
<input type="hidden" id="hashedPartnerId" value="{{ hashedPartnerId }}" />
|
|
191
|
-
<input minlength="9" maxlength="9" id="customNric" placeholder="NRIC" value="S1234567A" style="width: 100%; border: 2px solid #ccc; border-radius: 5px; background: white; color: rgb(42, 45, 51); text-align: left;">
|
|
192
|
-
<input type="hidden" id="customNricSamlArt" name="SAMLart" />
|
|
193
|
-
<button autofocus="" type="submit">Login</button>
|
|
194
|
-
<br>
|
|
195
|
-
<br>
|
|
196
|
-
{{/assertEndpoint}}
|
|
197
|
-
<div class="login__footer">
|
|
198
|
-
<div class="login-note">
|
|
199
|
-
Forgot <a aria-label="Forgot MockPass ID">MockPass ID</a> or <a aria-label="Forgot password">password</a>?
|
|
200
|
-
</div>
|
|
201
|
-
<div class="sp-reglink">
|
|
202
|
-
Don't have an account? <a aria-label="Register now">Register now</a>
|
|
203
|
-
</div>
|
|
204
|
-
</div>
|
|
205
210
|
</form>
|
|
211
|
+
{{/customProfileConfig}}
|
|
212
|
+
<br>
|
|
213
|
+
<div class="login__footer">
|
|
214
|
+
<div class="login-note">
|
|
215
|
+
Forgot <a aria-label="Forgot MockPass ID">MockPass ID</a> or <a aria-label="Forgot password">password</a>?
|
|
216
|
+
</div>
|
|
217
|
+
<div class="sp-reglink">
|
|
218
|
+
Don't have an account? <a aria-label="Register now">Register now</a>
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
206
221
|
</div>
|
|
207
222
|
<div class="clearfix"></div>
|
|
208
223
|
</div>
|
|
@@ -268,4 +283,4 @@
|
|
|
268
283
|
</div>
|
|
269
284
|
</div>
|
|
270
285
|
</body>
|
|
271
|
-
</html>
|
|
286
|
+
</html>
|
package/static/myinfo/v3.json
CHANGED
|
@@ -9765,6 +9765,12 @@
|
|
|
9765
9765
|
"value": "97399245"
|
|
9766
9766
|
}
|
|
9767
9767
|
},
|
|
9768
|
+
"uuid": {
|
|
9769
|
+
"lastupdated": "2020-04-16",
|
|
9770
|
+
"source": "1",
|
|
9771
|
+
"classification": "C",
|
|
9772
|
+
"value": "b6cb9308-733b-42b3-ad38-9b5afc890206"
|
|
9773
|
+
},
|
|
9768
9774
|
"mailadd": {
|
|
9769
9775
|
"country": {
|
|
9770
9776
|
"code": "SG",
|
|
@@ -27681,7 +27687,7 @@
|
|
|
27681
27687
|
"lastupdated": "2020-04-16",
|
|
27682
27688
|
"source": "1",
|
|
27683
27689
|
"classification": "C",
|
|
27684
|
-
"value": ""
|
|
27690
|
+
"value": "632d6cd6-7125-4fcc-b21b-e33aedf05806"
|
|
27685
27691
|
},
|
|
27686
27692
|
"mailadd": {
|
|
27687
27693
|
"country": {
|