@opengovsg/mockpass 4.0.7 → 4.0.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.
@@ -8,20 +8,13 @@ jobs:
8
8
  name: CI
9
9
  runs-on: ubuntu-latest
10
10
  steps:
11
- - uses: actions/checkout@v2
11
+ - uses: actions/checkout@v4
12
12
  - name: Use Node.js
13
- uses: actions/setup-node@v1
13
+ uses: actions/setup-node@v3
14
14
  with:
15
- node-version: '12.x'
16
- - name: Cache Node.js modules
17
- uses: actions/cache@v2
18
- with:
19
- # npm cache files are stored in `~/.npm` on Linux/macOS
20
- path: ~/.npm
21
- key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }}
22
- restore-keys: |
23
- ${{ runner.OS }}-node-
24
- ${{ runner.OS }}-
15
+ node-version: 'lts/*'
16
+ cache: 'npm'
17
+ cache-dependency-path: '**/package-lock.json'
25
18
  - run: npm ci
26
19
  - run: npx lockfile-lint --type npm --path package-lock.json --validate-https --allowed-hosts npm
27
20
  - run: npm run lint
@@ -5,7 +5,7 @@ const path = require('path')
5
5
  const express = require('express')
6
6
  const { pick, partition } = require('lodash')
7
7
 
8
- const jose = require('node-jose')
8
+ const jose = require('jose')
9
9
  const jwt = require('jsonwebtoken')
10
10
 
11
11
  const assertions = require('../../assertions')
@@ -31,16 +31,27 @@ module.exports =
31
31
  }
32
32
 
33
33
  const encryptPersona = async (persona) => {
34
- const signedPersona = jwt.sign(persona, MOCKPASS_PRIVATE_KEY, {
35
- algorithm: 'RS256',
36
- })
37
- const serviceCertAsKey = await jose.JWK.asKey(serviceProvider.cert, 'pem')
38
- const encryptedAndSignedPersona = await jose.JWE.createEncrypt(
39
- { format: 'compact' },
40
- serviceCertAsKey,
34
+ /*
35
+ * We sign and encrypt the persona. It's important to note that although a signature is
36
+ * usually derived from the payload hash and is thus much smaller than the payload itself,
37
+ * we're specifically contructeding a JWT, which contains the original payload.
38
+ *
39
+ * We then construct a JWE and provide two headers specifying the encryption algorithms used.
40
+ * You can read about them here: https://www.rfc-editor.org/rfc/inline-errata/rfc7518.html
41
+ *
42
+ * These values weren't picked arbitrarily; they were the defaults used by a library we
43
+ * formerly used: node-jose. We opted to continue using them for backwards compatibility.
44
+ */
45
+ const privateKey = await jose.importPKCS8(MOCKPASS_PRIVATE_KEY.toString())
46
+ const sign = await new jose.SignJWT(persona)
47
+ .setProtectedHeader({ alg: 'RS256' })
48
+ .sign(privateKey)
49
+ const publicKey = await jose.importX509(serviceProvider.cert.toString())
50
+ const encryptedAndSignedPersona = await new jose.CompactEncrypt(
51
+ Buffer.from(sign),
41
52
  )
42
- .update(JSON.stringify(signedPersona))
43
- .final()
53
+ .setProtectedHeader({ alg: 'RSA-OAEP', enc: 'A128CBC-HS256' })
54
+ .encrypt(publicKey)
44
55
  return encryptedAndSignedPersona
45
56
  }
46
57
 
@@ -142,7 +153,6 @@ module.exports =
142
153
  redirect_uri,
143
154
  })
144
155
  : {}
145
-
146
156
  if (!tokenTemplate) {
147
157
  res.status(400).send({
148
158
  code: 400,
@@ -4,7 +4,7 @@
4
4
  const express = require('express')
5
5
  const fs = require('fs')
6
6
  const { render } = require('mustache')
7
- const jose = require('node-jose')
7
+ const jose = require('jose')
8
8
  const path = require('path')
9
9
 
10
10
  const assertions = require('../../assertions')
@@ -32,6 +32,81 @@ const rpPublic = fs.readFileSync(
32
32
  path.resolve(__dirname, '../../../static/certs/oidc-v2-rp-public.json'),
33
33
  )
34
34
 
35
+ const singpass_token_endpoint_auth_signing_alg_values_supported = [
36
+ 'ES256',
37
+ 'ES384',
38
+ 'ES512',
39
+ ]
40
+
41
+ const corppass_token_endpoint_auth_signing_alg_values_supported = ['ES256']
42
+
43
+ const token_endpoint_auth_signing_alg_values_supported = {
44
+ singPass: singpass_token_endpoint_auth_signing_alg_values_supported,
45
+ corpPass: corppass_token_endpoint_auth_signing_alg_values_supported,
46
+ }
47
+
48
+ const singpass_id_token_encryption_alg_values_supported = [
49
+ 'ECDH-ES+A256KW',
50
+ 'ECDH-ES+A192KW',
51
+ 'ECDH-ES+A128KW',
52
+ 'RSA-OAEP-256',
53
+ ]
54
+
55
+ const corppass_id_token_encryption_alg_values_supported = ['ECDH-ES+A256KW']
56
+
57
+ const id_token_encryption_alg_values_supported = {
58
+ singPass: singpass_id_token_encryption_alg_values_supported,
59
+ corpPass: corppass_id_token_encryption_alg_values_supported,
60
+ }
61
+
62
+ function findEncryptionKey(jwks) {
63
+ let encryptionKey = jwks.keys.find(
64
+ (item) =>
65
+ item.use === 'enc' &&
66
+ item.kty === 'EC' &&
67
+ item.crv === 'P-521' &&
68
+ (item.alg === 'ECDH-ES+A256KW' || !item.alg),
69
+ )
70
+ if (encryptionKey) {
71
+ return { ...encryptionKey, alg: 'ECDH-ES+A256KW' }
72
+ }
73
+ if (!encryptionKey) {
74
+ encryptionKey = jwks.keys.find(
75
+ (item) =>
76
+ item.use === 'enc' &&
77
+ item.kty === 'EC' &&
78
+ item.crv === 'P-384' &&
79
+ (item.alg === 'ECDH-ES+A192KW' || !item.alg),
80
+ )
81
+ }
82
+ if (encryptionKey) {
83
+ return { ...encryptionKey, alg: 'ECDH-ES+A192KW' }
84
+ }
85
+ if (!encryptionKey) {
86
+ encryptionKey = jwks.keys.find(
87
+ (item) =>
88
+ item.use === 'enc' &&
89
+ item.kty === 'EC' &&
90
+ item.crv === 'P-256' &&
91
+ (item.alg === 'ECDH-ES+A128KW' || !item.alg),
92
+ )
93
+ }
94
+ if (encryptionKey) {
95
+ return { ...encryptionKey, alg: 'ECDH-ES+A128KW' }
96
+ }
97
+ if (!encryptionKey) {
98
+ encryptionKey = jwks.keys.find(
99
+ (item) =>
100
+ item.use === 'enc' &&
101
+ item.kty === 'RSA' &&
102
+ (item.alg === 'RSA-OAEP-256' || !item.alg),
103
+ )
104
+ }
105
+ if (encryptionKey) {
106
+ return { ...encryptionKey, alg: 'RSA-OAEP-256' }
107
+ }
108
+ }
109
+
35
110
  function config(app, { showLoginPage }) {
36
111
  for (const idp of ['singPass', 'corpPass']) {
37
112
  const profiles = assertions.oidc[idp]
@@ -146,18 +221,21 @@ function config(app, { showLoginPage }) {
146
221
 
147
222
  // Only SP requires client_id
148
223
  if (idp === 'singPass' && !client_id) {
224
+ console.error('Missing client_id')
149
225
  return res.status(400).send({
150
226
  error: 'invalid_request',
151
227
  error_description: 'Missing client_id',
152
228
  })
153
229
  }
154
230
  if (!redirectURI) {
231
+ console.error('Missing redirect_uri')
155
232
  return res.status(400).send({
156
233
  error: 'invalid_request',
157
234
  error_description: 'Missing redirect_uri',
158
235
  })
159
236
  }
160
237
  if (grant_type !== 'authorization_code') {
238
+ console.error('Unknown grant_type', grant_type)
161
239
  return res.status(400).send({
162
240
  error: 'unsupported_grant_type',
163
241
  error_description: `Unknown grant_type ${grant_type}`,
@@ -173,12 +251,14 @@ function config(app, { showLoginPage }) {
173
251
  client_assertion_type !==
174
252
  'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
175
253
  ) {
254
+ console.error('Unknown client_assertion_type', client_assertion_type)
176
255
  return res.status(400).send({
177
256
  error: 'invalid_request',
178
257
  error_description: `Unknown client_assertion_type ${client_assertion_type}`,
179
258
  })
180
259
  }
181
260
  if (!clientAssertion) {
261
+ console.error('Missing client_assertion')
182
262
  return res.status(400).send({
183
263
  error: 'invalid_request',
184
264
  error_description: 'Missing client_assertion',
@@ -195,10 +275,19 @@ function config(app, { showLoginPage }) {
195
275
 
196
276
  if (rpJwksEndpoint) {
197
277
  try {
198
- rpKeysetString = await fetch(rpJwksEndpoint, {
278
+ const rpKeysetResponse = await fetch(rpJwksEndpoint, {
199
279
  method: 'GET',
200
- }).then((response) => response.text())
280
+ })
281
+ rpKeysetString = await rpKeysetResponse.text()
282
+ if (!rpKeysetResponse.ok) {
283
+ throw new Error(rpKeysetString)
284
+ }
201
285
  } catch (e) {
286
+ console.error(
287
+ 'Failed to fetch RP JWKS from',
288
+ rpJwksEndpoint,
289
+ e.message,
290
+ )
202
291
  return res.status(400).send({
203
292
  error: 'invalid_client',
204
293
  error_description: `Failed to fetch RP JWKS from specified endpoint: ${e.message}`,
@@ -213,40 +302,70 @@ function config(app, { showLoginPage }) {
213
302
  try {
214
303
  rpKeysetJson = JSON.parse(rpKeysetString)
215
304
  } catch (e) {
305
+ console.error('Unable to parse RP keyset', e.message)
216
306
  return res.status(400).send({
217
307
  error: 'invalid_client',
218
308
  error_description: `Unable to parse RP keyset: ${e.message}`,
219
309
  })
220
310
  }
221
311
 
222
- const rpKeyset = await jose.JWK.asKeyStore(rpKeysetJson)
223
-
312
+ const rpKeyset = jose.createLocalJWKSet(rpKeysetJson)
224
313
  // Step 0.5: Verify client assertion with RP signing key
225
- let clientAssertionVerified
314
+ let clientAssertionResult
226
315
  try {
227
- clientAssertionVerified = await jose.JWS.createVerify(
316
+ clientAssertionResult = await jose.jwtVerify(
317
+ clientAssertion,
228
318
  rpKeyset,
229
- ).verify(clientAssertion)
319
+ )
230
320
  } catch (e) {
231
- return res.status(400).send({
321
+ console.error(
322
+ 'Unable to verify client_assertion',
323
+ e.message,
324
+ clientAssertion,
325
+ )
326
+ return res.status(401).send({
232
327
  error: 'invalid_client',
233
328
  error_description: `Unable to verify client_assertion: ${e.message}`,
234
329
  })
235
330
  }
236
331
 
237
- let clientAssertionClaims
238
- try {
239
- clientAssertionClaims = JSON.parse(clientAssertionVerified.payload)
240
- } catch (e) {
241
- return res.status(400).send({
332
+ const { payload: clientAssertionClaims, protectedHeader } =
333
+ clientAssertionResult
334
+ console.debug(
335
+ 'Received client_assertion',
336
+ clientAssertionClaims,
337
+ protectedHeader,
338
+ )
339
+ if (
340
+ !token_endpoint_auth_signing_alg_values_supported[idp].some(
341
+ (item) => item === protectedHeader.alg,
342
+ )
343
+ ) {
344
+ console.warn(
345
+ 'The client_assertion alg',
346
+ protectedHeader.alg,
347
+ 'does not meet required token_endpoint_auth_signing_alg_values_supported',
348
+ token_endpoint_auth_signing_alg_values_supported[idp],
349
+ )
350
+ }
351
+
352
+ if (!protectedHeader.typ) {
353
+ console.error('The client_assertion typ should be set')
354
+ return res.status(401).send({
242
355
  error: 'invalid_client',
243
- error_description: `Unable to parse client_assertion: ${e.message}`,
356
+ error_description: 'The client_assertion typ should be set',
244
357
  })
245
358
  }
246
359
 
247
360
  if (idp === 'singPass') {
248
361
  if (clientAssertionClaims['sub'] !== client_id) {
249
- return res.status(400).send({
362
+ console.error(
363
+ 'Incorrect sub in client_assertion claims. Found',
364
+ clientAssertionClaims['sub'],
365
+ 'but should be',
366
+ client_id,
367
+ )
368
+ return res.status(401).send({
250
369
  error: 'invalid_client',
251
370
  error_description: 'Incorrect sub in client_assertion claims',
252
371
  })
@@ -255,7 +374,8 @@ function config(app, { showLoginPage }) {
255
374
  // Since client_id is not given for corpPass, sub claim is required in
256
375
  // order to get aud for id_token.
257
376
  if (!clientAssertionClaims['sub']) {
258
- return res.status(400).send({
377
+ console.error('Missing sub in client_assertion claims')
378
+ return res.status(401).send({
259
379
  error: 'invalid_client',
260
380
  error_description: 'Missing sub in client_assertion claims',
261
381
  })
@@ -268,7 +388,13 @@ function config(app, { showLoginPage }) {
268
388
  )}/${idp.toLowerCase()}/v2`
269
389
 
270
390
  if (clientAssertionClaims['aud'] !== iss) {
271
- return res.status(400).send({
391
+ console.error(
392
+ 'Incorrect aud in client_assertion claims. Found',
393
+ clientAssertionClaims['aud'],
394
+ 'but should be',
395
+ iss,
396
+ )
397
+ return res.status(401).send({
272
398
  error: 'invalid_client',
273
399
  error_description: 'Incorrect aud in client_assertion claims',
274
400
  })
@@ -279,38 +405,65 @@ function config(app, { showLoginPage }) {
279
405
 
280
406
  // Step 2: Get ID token
281
407
  const aud = clientAssertionClaims['sub']
282
- console.warn(
283
- `Received auth code ${authCode} from ${aud} and ${redirectURI}`,
284
- )
408
+ console.debug('Received token request', {
409
+ code: authCode,
410
+ client_id: aud,
411
+ redirect_uri: redirectURI,
412
+ })
285
413
 
286
414
  const { idTokenClaims, accessToken } = await assertions.oidc.create[
287
415
  idp
288
416
  ](profile, iss, aud, nonce)
289
417
 
290
418
  // Step 3: Sign ID token with ASP signing key
291
- const signingKey = await jose.JWK.asKeyStore(
292
- JSON.parse(aspSecret),
293
- ).then((keystore) => keystore.get({ use: 'sig' }))
294
-
295
- const signedIdToken = await jose.JWS.createSign(
296
- { format: 'compact' },
297
- signingKey,
419
+ const aspKeyset = JSON.parse(aspSecret)
420
+ const aspSigningKey = aspKeyset.keys.find(
421
+ (item) =>
422
+ item.use === 'sig' && item.kty === 'EC' && item.crv === 'P-256',
423
+ )
424
+ if (!aspSigningKey) {
425
+ console.error('No suitable signing key found', aspKeyset.keys)
426
+ return res.status(400).send({
427
+ error: 'invalid_request',
428
+ error_description: 'No suitable signing key found',
429
+ })
430
+ }
431
+ const signingKey = await jose.importJWK(aspSigningKey, 'ES256')
432
+ const signedProtectedHeader = {
433
+ alg: 'ES256',
434
+ typ: 'JWT',
435
+ kid: signingKey.kid,
436
+ }
437
+ const signedIdToken = await new jose.CompactSign(
438
+ new TextEncoder().encode(JSON.stringify(idTokenClaims)),
298
439
  )
299
- .update(JSON.stringify(idTokenClaims))
300
- .final()
440
+ .setProtectedHeader(signedProtectedHeader)
441
+ .sign(signingKey)
301
442
 
302
443
  // Step 4: Encrypt ID token with RP encryption key
303
- // We're using the first encryption key we find, although NDI actually
304
- // has its own selection criteria.
305
- const encryptionKey = rpKeyset.get({ use: 'enc' })
306
-
307
- const idToken = await jose.JWE.createEncrypt(
308
- { format: 'compact', fields: { cty: 'JWT' } },
309
- encryptionKey,
444
+ const rpEncryptionKey = findEncryptionKey(rpKeysetJson)
445
+ if (!rpEncryptionKey) {
446
+ console.error('No suitable encryption key found', rpKeysetJson.keys)
447
+ return res.status(400).send({
448
+ error: 'invalid_request',
449
+ error_description: 'No suitable encryption key found',
450
+ })
451
+ }
452
+ console.debug('Using encryption key', rpEncryptionKey)
453
+ const encryptedProtectedHeader = {
454
+ alg: rpEncryptionKey.alg,
455
+ typ: 'JWT',
456
+ kid: rpEncryptionKey.kid,
457
+ enc: 'A256CBC-HS512',
458
+ cty: 'JWT',
459
+ }
460
+ const idToken = await new jose.CompactEncrypt(
461
+ new TextEncoder().encode(signedIdToken),
310
462
  )
311
- .update(signedIdToken)
312
- .final()
463
+ .setProtectedHeader(encryptedProtectedHeader)
464
+ .encrypt(await jose.importJWK(rpEncryptionKey, rpEncryptionKey.alg))
313
465
 
466
+ console.debug('ID Token', idToken)
314
467
  // Step 5: Send token
315
468
  res.status(200).send({
316
469
  access_token: accessToken,
@@ -342,20 +495,23 @@ function config(app, { showLoginPage }) {
342
495
  grant_types_supported: ['authorization_code'],
343
496
  token_endpoint: `${baseUrl}/token`,
344
497
  token_endpoint_auth_methods_supported: ['private_key_jwt'],
345
- token_endpoint_auth_signing_alg_values_supported: ['ES512'], // omits ES256 and ES384 (allowed in SP)
498
+ token_endpoint_auth_signing_alg_values_supported:
499
+ token_endpoint_auth_signing_alg_values_supported[idp],
346
500
  id_token_signing_alg_values_supported: ['ES256'],
347
- id_token_encryption_alg_values_supported: ['ECDH-ES+A256KW'], // omits ECDH-ES+A192KW, ECDH-ES+A128KW and RSA-OAEP-256 (allowed in SP)
501
+ id_token_encryption_alg_values_supported:
502
+ id_token_encryption_alg_values_supported[idp],
348
503
  id_token_encryption_enc_values_supported: ['A256CBC-HS512'],
349
504
  }
350
505
 
351
506
  if (idp === 'corpPass') {
352
- data['claims_supported'].push([
507
+ data['claims_supported'] = [
508
+ ...data['claims_supported'],
353
509
  'userInfo',
354
- 'entityInfo',
510
+ 'EntityInfo',
355
511
  'rt_hash',
356
512
  'at_hash',
357
513
  'amr',
358
- ])
514
+ ]
359
515
  // Omit authorization-info_endpoint for CP
360
516
  }
361
517
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengovsg/mockpass",
3
- "version": "4.0.7",
3
+ "version": "4.0.9",
4
4
  "description": "A mock SingPass/CorpPass server for dev purposes",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -41,13 +41,13 @@
41
41
  "dotenv": "^16.0.0",
42
42
  "expiry-map": "^2.0.0",
43
43
  "express": "^4.16.3",
44
+ "jose": "^4.14.4",
44
45
  "jsonwebtoken": "^9.0.0",
45
46
  "lodash": "^4.17.11",
46
47
  "morgan": "^1.9.1",
47
48
  "mustache": "^4.2.0",
48
49
  "node-jose": "^2.0.0",
49
- "uuid": "^9.0.0",
50
- "xpath": "0.0.32"
50
+ "uuid": "^9.0.0"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@commitlint/cli": "^17.1.2",
@@ -7,6 +7,15 @@
7
7
  "kid": "sig-1655709297",
8
8
  "x": "AWuSHLkeP89DOkPaTs6MUDTFX1oL_Nr2rsJxCUyWL9x4LDEwtGXxWmw5-KhJSKauwJL2fAiNribZa2E0EZ-A4DzL",
9
9
  "y": "AHoghl5OGyp7Vejt2sqYW7z2G_gTGBDR9q-ylLjnERpKd7-kHybLEutkwp5tmkhhlOysCcXE7vpTcnwxeQPa3zN0"
10
- }
10
+ },
11
+ {
12
+ "kty": "EC",
13
+ "use": "sig",
14
+ "crv": "P-256",
15
+ "kid": "ndi_mock_01",
16
+ "x": "ZyAP_T3GS6tzdEfIKgj7Z_TkKWQ9AQAU7LNTSV_JICQ",
17
+ "y": "gxQgPvGD8ASZT7DT41pgWP4ZHiZ_7HGcMoDM0NEOfO8",
18
+ "alg": "ES256"
19
+ }
11
20
  ]
12
21
  }
@@ -8,6 +8,15 @@
8
8
  "kid": "sig-1655709297",
9
9
  "x": "AWuSHLkeP89DOkPaTs6MUDTFX1oL_Nr2rsJxCUyWL9x4LDEwtGXxWmw5-KhJSKauwJL2fAiNribZa2E0EZ-A4DzL",
10
10
  "y": "AHoghl5OGyp7Vejt2sqYW7z2G_gTGBDR9q-ylLjnERpKd7-kHybLEutkwp5tmkhhlOysCcXE7vpTcnwxeQPa3zN0"
11
- }
11
+ },
12
+ {
13
+ "kty": "EC",
14
+ "d": "_nXJySWym8zFj_jL3skM2zf0wxL8GQo10WgC3nrx3vw",
15
+ "use": "sig",
16
+ "crv": "P-256",
17
+ "kid": "ndi_mock_01",
18
+ "x": "ZyAP_T3GS6tzdEfIKgj7Z_TkKWQ9AQAU7LNTSV_JICQ",
19
+ "y": "gxQgPvGD8ASZT7DT41pgWP4ZHiZ_7HGcMoDM0NEOfO8"
20
+ }
12
21
  ]
13
22
  }