@opengovsg/mockpass 4.4.2 → 4.5.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/lib/assertions.js CHANGED
@@ -186,7 +186,7 @@ const oidc = {
186
186
  aud,
187
187
  }
188
188
 
189
- const sub = `s=${nric},u=${uuid},c=SG`
189
+ const sub = `s=${nric},uuid=${uuid},u=${uen}${nric},c=SG`
190
190
 
191
191
  const accessTokenClaims = {
192
192
  ...baseClaims,
package/lib/auth-code.js CHANGED
@@ -5,16 +5,16 @@ const AUTH_CODE_TIMEOUT = 5 * 60 * 1000
5
5
  const profileAndNonceStore = new ExpiryMap(AUTH_CODE_TIMEOUT)
6
6
 
7
7
  const generateAuthCode = (
8
- { profile, scopes, nonce },
8
+ { profile, scopes, nonce, clientId = '' },
9
9
  { isStateless = false },
10
10
  ) => {
11
11
  const authCode = isStateless
12
- ? Buffer.from(JSON.stringify({ profile, scopes, nonce })).toString(
13
- 'base64url',
14
- )
12
+ ? Buffer.from(
13
+ JSON.stringify({ profile, scopes, nonce, clientId }),
14
+ ).toString('base64url')
15
15
  : crypto.randomBytes(45).toString('base64')
16
16
 
17
- profileAndNonceStore.set(authCode, { profile, scopes, nonce })
17
+ profileAndNonceStore.set(authCode, { profile, scopes, nonce, clientId })
18
18
  return authCode
19
19
  }
20
20
 
@@ -59,6 +59,17 @@ const id_token_encryption_alg_values_supported = {
59
59
  corpPass: corppass_id_token_encryption_alg_values_supported,
60
60
  }
61
61
 
62
+ const singpass_userinfo_encryption_alg_values_supported = [
63
+ 'ECDH-ES+A256KW',
64
+ 'ECDH-ES+A192KW',
65
+ 'ECDH-ES+A128KW',
66
+ ]
67
+
68
+ const userinfo_encryption_alg_values_supported = {
69
+ singPass: singpass_userinfo_encryption_alg_values_supported,
70
+ // Corppass to be added in the future
71
+ }
72
+
62
73
  function findEcdhEsEncryptionKey(jwks, crv, algs) {
63
74
  let encryptionKey = jwks.keys.find(
64
75
  (item) =>
@@ -148,7 +159,7 @@ function config(app, { showLoginPage, isStateless }) {
148
159
 
149
160
  app.get(`/${idp.toLowerCase()}/v2/authorize`, (req, res) => {
150
161
  const {
151
- scope,
162
+ scope: scopes,
152
163
  response_type,
153
164
  client_id,
154
165
  redirect_uri: redirectURI,
@@ -156,10 +167,19 @@ function config(app, { showLoginPage, isStateless }) {
156
167
  nonce,
157
168
  } = req.query
158
169
 
159
- if (scope !== 'openid') {
170
+ if (typeof scopes !== 'string') {
160
171
  return res.status(400).send({
161
172
  error: 'invalid_scope',
162
- error_description: `Unknown scope ${scope}`,
173
+ error_description: `Unknown scope ${scopes}`,
174
+ })
175
+ }
176
+
177
+ const scopeArr = scopes.split(' ')
178
+
179
+ if (!scopeArr.includes('openid')) {
180
+ return res.status(400).send({
181
+ error: 'invalid_request',
182
+ error_description: `Missing mandatory openid scope`,
163
183
  })
164
184
  }
165
185
  if (response_type !== 'code') {
@@ -196,7 +216,10 @@ function config(app, { showLoginPage, isStateless }) {
196
216
  // Identical to OIDC v1
197
217
  if (showLoginPage(req)) {
198
218
  const values = profiles.map((profile) => {
199
- const authCode = generateAuthCode({ profile, nonce }, { isStateless })
219
+ const authCode = generateAuthCode(
220
+ { profile, scopes, nonce, clientId: client_id },
221
+ { isStateless },
222
+ )
200
223
  const assertURL = buildAssertURL(redirectURI, authCode, state)
201
224
  const id = idGenerator[idp](profile)
202
225
  return { id, assertURL }
@@ -215,7 +238,10 @@ function config(app, { showLoginPage, isStateless }) {
215
238
  res.send(response)
216
239
  } else {
217
240
  const profile = customProfileFromHeaders[idp](req) || defaultProfile
218
- const authCode = generateAuthCode({ profile, nonce }, { isStateless })
241
+ const authCode = generateAuthCode(
242
+ { profile, scopes, nonce, clientId: client_id },
243
+ { isStateless },
244
+ )
219
245
  const assertURL = buildAssertURL(redirectURI, authCode, state)
220
246
  console.warn(
221
247
  `Redirecting login from ${req.query.client_id} to ${redirectURI}`,
@@ -299,50 +325,9 @@ function config(app, { showLoginPage, isStateless }) {
299
325
  }
300
326
 
301
327
  // Step 0: Get the RP keyset
302
- const rpJwksEndpoint =
303
- idp === 'singPass'
304
- ? process.env.SP_RP_JWKS_ENDPOINT
305
- : process.env.CP_RP_JWKS_ENDPOINT
306
-
307
- let rpKeysetString
308
-
309
- if (rpJwksEndpoint) {
310
- try {
311
- const rpKeysetResponse = await fetch(rpJwksEndpoint, {
312
- method: 'GET',
313
- })
314
- rpKeysetString = await rpKeysetResponse.text()
315
- if (!rpKeysetResponse.ok) {
316
- throw new Error(rpKeysetString)
317
- }
318
- } catch (e) {
319
- console.error(
320
- 'Failed to fetch RP JWKS from',
321
- rpJwksEndpoint,
322
- e.message,
323
- )
324
- return res.status(400).send({
325
- error: 'invalid_client',
326
- error_description: `Failed to fetch RP JWKS from specified endpoint: ${e.message}`,
327
- })
328
- }
329
- } else {
330
- // If the endpoint is not defined, default to the sample keyset we provided.
331
- rpKeysetString = rpPublic
332
- }
333
-
334
- let rpKeysetJson
335
- try {
336
- rpKeysetJson = JSON.parse(rpKeysetString)
337
- } catch (e) {
338
- console.error('Unable to parse RP keyset', e.message)
339
- return res.status(400).send({
340
- error: 'invalid_client',
341
- error_description: `Unable to parse RP keyset: ${e.message}`,
342
- })
343
- }
344
-
328
+ const rpKeysetJson = await fetchRpJwks({ idp, res })
345
329
  const rpKeyset = jose.createLocalJWKSet(rpKeysetJson)
330
+
346
331
  // Step 0.5: Verify client assertion with RP signing key
347
332
  let clientAssertionResult
348
333
  try {
@@ -426,7 +411,9 @@ function config(app, { showLoginPage, isStateless }) {
426
411
  }
427
412
 
428
413
  // Step 1: Obtain profile for which the auth code requested data for
429
- const { profile, nonce } = lookUpByAuthCode(authCode, { isStateless })
414
+ const { profile, nonce } = lookUpByAuthCode(authCode, {
415
+ isStateless,
416
+ })
430
417
 
431
418
  // Step 2: Get ID token
432
419
  const aud = clientAssertionClaims['sub']
@@ -436,16 +423,18 @@ function config(app, { showLoginPage, isStateless }) {
436
423
  redirect_uri: redirectURI,
437
424
  })
438
425
 
439
- const { idTokenClaims, accessToken } = await assertions.oidc.create[
440
- idp
441
- ](profile, iss, aud, nonce)
426
+ const { idTokenClaims } = await assertions.oidc.create[idp](
427
+ profile,
428
+ iss,
429
+ aud,
430
+ nonce,
431
+ )
432
+
433
+ const accessToken = authCode
442
434
 
443
435
  // Step 3: Sign ID token with ASP signing key
444
436
  const aspKeyset = JSON.parse(aspSecret)
445
- const aspSigningKey = aspKeyset.keys.find(
446
- (item) =>
447
- item.use === 'sig' && item.kty === 'EC' && item.crv === 'P-256',
448
- )
437
+ const aspSigningKey = await getSigKey({ keySet: aspKeyset })
449
438
  if (!aspSigningKey) {
450
439
  console.error('No suitable signing key found', aspKeyset.keys)
451
440
  return res.status(400).send({
@@ -453,6 +442,7 @@ function config(app, { showLoginPage, isStateless }) {
453
442
  error_description: 'No suitable signing key found',
454
443
  })
455
444
  }
445
+
456
446
  const signingKey = await jose.importJWK(aspSigningKey, 'ES256')
457
447
  const signedProtectedHeader = {
458
448
  alg: 'ES256',
@@ -556,6 +546,19 @@ function config(app, { showLoginPage, isStateless }) {
556
546
  // Omit authorization-info_endpoint for CP
557
547
  }
558
548
 
549
+ if (idp === 'singPass') {
550
+ data['scopes_supported'] = [
551
+ ...data['scopes_supported'],
552
+ 'uinfin',
553
+ 'name',
554
+ ]
555
+ data['userinfo_endpoint'] = `${baseUrl}/userinfo`
556
+ data['userinfo_signing_alg_values_supported'] = ['ES256']
557
+ data['userinfo_encryption_alg_values_supported'] =
558
+ userinfo_encryption_alg_values_supported[idp]
559
+ data['userinfo_encryption_enc_values_supported'] = ['A256GCM']
560
+ }
561
+
559
562
  res.status(200).send(data)
560
563
  },
561
564
  )
@@ -563,8 +566,186 @@ function config(app, { showLoginPage, isStateless }) {
563
566
  app.get(`/${idp.toLowerCase()}/v2/.well-known/keys`, (req, res) => {
564
567
  res.status(200).send(JSON.parse(aspPublic))
565
568
  })
569
+
570
+ app.get(`/${idp.toLowerCase()}/v2/userinfo`, async (req, res) => {
571
+ const { protocol, headers } = req
572
+ const host = req.get('host')
573
+
574
+ const authCode = extractBearerTokenFromHeader(headers, res)
575
+
576
+ const { profile, scopes, clientId } = lookUpByAuthCode(authCode, {
577
+ isStateless,
578
+ })
579
+
580
+ const scopesArr = scopes.split(' ')
581
+ console.log('userinfo scopes: ', scopesArr)
582
+
583
+ const { uuid } = profile
584
+ const { nric } = assertions.oidc.singPass.find((p) => p.uuid === uuid)
585
+ const name = `USER ${nric}`
586
+
587
+ const iss = `${protocol}://${host}/${idp.toLowerCase()}/v2`
588
+ const aud = clientId
589
+
590
+ // Reuse the sub generation logic that was meant for ID token
591
+ const {
592
+ idTokenClaims: { sub, iat },
593
+ } = await assertions.oidc.create[idp](profile, iss, aud)
594
+
595
+ const userinfoPayload = {
596
+ sub,
597
+ iss,
598
+ aud,
599
+ iat,
600
+ }
601
+
602
+ const claimMap = { uinfin: nric, name }
603
+ for (const [claim, value] of Object.entries(claimMap)) {
604
+ if (scopesArr.includes(claim)) {
605
+ attachClaim(userinfoPayload, claim, value)
606
+ }
607
+ }
608
+
609
+ console.debug('userinfo payload:', userinfoPayload)
610
+
611
+ const rpKeysetJson = await fetchRpJwks({ idp, res })
612
+ const aspKeyset = JSON.parse(aspSecret)
613
+ const aspSigningKey = await getSigKey({ keySet: aspKeyset })
614
+
615
+ if (!aspSigningKey) {
616
+ console.error('No suitable signing key found', aspKeyset.keys)
617
+ return res.status(400).send({
618
+ error: 'invalid_request',
619
+ error_description: 'No suitable signing key found',
620
+ })
621
+ }
622
+
623
+ const signingKey = await jose.importJWK(aspSigningKey, 'ES256')
624
+ const signedProtectedHeader = {
625
+ alg: 'ES256',
626
+ typ: 'JWT',
627
+ kid: aspSigningKey.kid,
628
+ }
629
+
630
+ const textEncoder = new TextEncoder()
631
+ const userinfoJws = await new jose.CompactSign(
632
+ textEncoder.encode(JSON.stringify(userinfoPayload)),
633
+ )
634
+ .setProtectedHeader(signedProtectedHeader)
635
+ .sign(signingKey)
636
+
637
+ console.debug('userinfo JWS:', userinfoJws)
638
+
639
+ const rpEncryptionKey = findEncryptionKey(
640
+ rpKeysetJson,
641
+ userinfo_encryption_alg_values_supported[idp],
642
+ )
643
+
644
+ console.debug('rp encrypted key', rpEncryptionKey)
645
+
646
+ const encryptedProtectedHeader = {
647
+ alg: rpEncryptionKey.alg,
648
+ typ: 'JWT',
649
+ kid: rpEncryptionKey.kid,
650
+ enc: 'A256CBC-HS512',
651
+ cty: 'JWT',
652
+ }
653
+
654
+ const encryptionKey = await jose.importJWK(rpEncryptionKey, 'ES256')
655
+ const userinfoJwe = await new jose.CompactEncrypt(
656
+ textEncoder.encode(userinfoJws),
657
+ )
658
+ .setProtectedHeader(encryptedProtectedHeader)
659
+ .encrypt(encryptionKey)
660
+
661
+ console.debug('userinfo JWE:', userinfoJwe)
662
+
663
+ return res.status(200).type('application/jwt').send(userinfoJwe)
664
+ })
566
665
  }
666
+
567
667
  return app
568
668
  }
569
669
 
670
+ const fetchRpJwks = async ({ idp, res }) => {
671
+ const rpJwksEndpoint =
672
+ idp === 'singPass'
673
+ ? process.env.SP_RP_JWKS_ENDPOINT
674
+ : process.env.CP_RP_JWKS_ENDPOINT
675
+
676
+ let rpKeysetString
677
+
678
+ if (rpJwksEndpoint) {
679
+ try {
680
+ const rpKeysetResponse = await fetch(rpJwksEndpoint, {
681
+ method: 'GET',
682
+ })
683
+ rpKeysetString = await rpKeysetResponse.text()
684
+ if (!rpKeysetResponse.ok) {
685
+ throw new Error(rpKeysetString)
686
+ }
687
+ } catch (e) {
688
+ console.error('Failed to fetch RP JWKS from', rpJwksEndpoint, e.message)
689
+ return res.status(400).send({
690
+ error: 'invalid_client',
691
+ error_description: `Failed to fetch RP JWKS from specified endpoint: ${e.message}`,
692
+ })
693
+ }
694
+ } else {
695
+ // If the endpoint is not defined, default to the sample keyset we provided.
696
+ rpKeysetString = rpPublic
697
+ }
698
+
699
+ let rpKeysetJson
700
+ try {
701
+ rpKeysetJson = JSON.parse(rpKeysetString)
702
+ } catch (e) {
703
+ console.error('Unable to parse RP keyset', e.message)
704
+ return res.status(400).send({
705
+ error: 'invalid_client',
706
+ error_description: `Unable to parse RP keyset: ${e.message}`,
707
+ })
708
+ }
709
+
710
+ return rpKeysetJson
711
+ }
712
+
713
+ const getSigKey = async ({ keySet, kty = 'EC', crv = 'P-256' }) => {
714
+ const signingKey = keySet.keys.find(
715
+ (item) => item.use === 'sig' && item.kty === kty && item.crv === crv,
716
+ )
717
+
718
+ return signingKey
719
+ }
720
+
721
+ const extractBearerTokenFromHeader = (headers, res) => {
722
+ const authHeader = headers.authorization || headers.Authorization
723
+
724
+ if (!authHeader) {
725
+ return res.status(401).send({
726
+ error: 'invalid_request',
727
+ error_description: 'Missing Authorization header',
728
+ })
729
+ }
730
+
731
+ const tokenParts = authHeader.trim().split(' ')
732
+ if (tokenParts.length !== 2 || tokenParts[0] !== 'Bearer' || !tokenParts[1]) {
733
+ return res.status(401).send({
734
+ error: 'invalid_request',
735
+ error_description: 'Malformed Authorization header',
736
+ })
737
+ }
738
+
739
+ return tokenParts[1]
740
+ }
741
+
742
+ const attachClaim = (payload, key, value) => {
743
+ payload[key] = {
744
+ lastupdated: '2023-03-23',
745
+ source: '1',
746
+ classification: 'C',
747
+ value,
748
+ }
749
+ }
750
+
570
751
  module.exports = config
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengovsg/mockpass",
3
- "version": "4.4.2",
3
+ "version": "4.5.0",
4
4
  "description": "A mock SingPass/CorpPass server for dev purposes",
5
5
  "main": "app.js",
6
6
  "bin": {
@@ -38,7 +38,7 @@
38
38
  "dependencies": {
39
39
  "base-64": "^1.0.0",
40
40
  "cookie-parser": "^1.4.3",
41
- "dotenv": "^16.0.0",
41
+ "dotenv": "^17.2.0",
42
42
  "expiry-map": "^2.0.0",
43
43
  "express": "^5.1.0",
44
44
  "jose": "^5.2.3",
@@ -58,7 +58,7 @@
58
58
  "commitizen": "^4.2.4",
59
59
  "cz-conventional-changelog": "^3.2.0",
60
60
  "eslint": "^9.8.0",
61
- "eslint-config-prettier": "^9.1.0",
61
+ "eslint-config-prettier": "^10.1.8",
62
62
  "eslint-plugin-prettier": "^4.0.0",
63
63
  "globals": "^16.0.0",
64
64
  "husky": "^9.0.11",