@opengovsg/mockpass 4.4.3 → 4.5.1

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 CHANGED
@@ -21,7 +21,7 @@ Configure your application to point to the following endpoints:
21
21
 
22
22
  - http://localhost:5156/singpass/v2/.well-known/openid-configuration
23
23
  - http://localhost:5156/singpass/v2/.well-known/keys
24
- - http://localhost:5156/singpass/v2/authorize
24
+ - http://localhost:5156/singpass/v2/auth
25
25
  - http://localhost:5156/singpass/v2/token
26
26
 
27
27
  Configure your application (or MockPass) with keys:
@@ -45,7 +45,7 @@ Configure your application to point to the following endpoints:
45
45
 
46
46
  - http://localhost:5156/corppass/v2/.well-known/openid-configuration
47
47
  - http://localhost:5156/corppass/v2/.well-known/keys
48
- - http://localhost:5156/corppass/v2/authorize
48
+ - http://localhost:5156/corppass/v2/auth
49
49
  - http://localhost:5156/corppass/v2/token
50
50
 
51
51
  Configure your application (or MockPass) with keys:
package/lib/assertions.js CHANGED
@@ -70,6 +70,7 @@ const oidc = {
70
70
  ...Object.keys(myinfo.v3.personas).map((nric) => ({
71
71
  nric,
72
72
  uuid: myinfo.v3.personas[nric].uuid.value,
73
+ claims: myinfo.v3.personas[nric],
73
74
  })),
74
75
  ],
75
76
  corpPass: [
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) =>
@@ -146,9 +157,9 @@ function config(app, { showLoginPage, isStateless }) {
146
157
  const defaultProfile =
147
158
  profiles.find((p) => p.nric === process.env.MOCKPASS_NRIC) || profiles[0]
148
159
 
149
- app.get(`/${idp.toLowerCase()}/v2/authorize`, (req, res) => {
160
+ app.get(`/${idp.toLowerCase()}/v2/auth`, (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 }
@@ -204,7 +227,7 @@ function config(app, { showLoginPage, isStateless }) {
204
227
  const response = render(LOGIN_TEMPLATE, {
205
228
  values,
206
229
  customProfileConfig: {
207
- endpoint: `/${idp.toLowerCase()}/v2/authorize/custom-profile`,
230
+ endpoint: `/${idp.toLowerCase()}/v2/auth/custom-profile`,
208
231
  showUuid: true,
209
232
  showUen: idp === 'corpPass',
210
233
  redirectURI,
@@ -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}`,
@@ -224,7 +250,7 @@ function config(app, { showLoginPage, isStateless }) {
224
250
  }
225
251
  })
226
252
 
227
- app.get(`/${idp.toLowerCase()}/v2/authorize/custom-profile`, (req, res) => {
253
+ app.get(`/${idp.toLowerCase()}/v2/auth/custom-profile`, (req, res) => {
228
254
  const { nric, uuid, uen, redirectURI, state, nonce } = req.query
229
255
 
230
256
  const profile = { nric, uuid }
@@ -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']
@@ -438,14 +425,11 @@ function config(app, { showLoginPage, isStateless }) {
438
425
 
439
426
  const { idTokenClaims, accessToken } = await assertions.oidc.create[
440
427
  idp
441
- ](profile, iss, aud, nonce)
428
+ ](profile, iss, aud, nonce, authCode)
442
429
 
443
430
  // Step 3: Sign ID token with ASP signing key
444
431
  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
- )
432
+ const aspSigningKey = await getSigKey({ keySet: aspKeyset })
449
433
  if (!aspSigningKey) {
450
434
  console.error('No suitable signing key found', aspKeyset.keys)
451
435
  return res.status(400).send({
@@ -453,6 +437,7 @@ function config(app, { showLoginPage, isStateless }) {
453
437
  error_description: 'No suitable signing key found',
454
438
  })
455
439
  }
440
+
456
441
  const signingKey = await jose.importJWK(aspSigningKey, 'ES256')
457
442
  const signedProtectedHeader = {
458
443
  alg: 'ES256',
@@ -527,7 +512,7 @@ function config(app, { showLoginPage, isStateless }) {
527
512
  // Note: does not support backchannel auth
528
513
  const data = {
529
514
  issuer: baseUrl,
530
- authorization_endpoint: `${baseUrl}/authorize`,
515
+ authorization_endpoint: `${baseUrl}/auth`,
531
516
  jwks_uri: `${baseUrl}/.well-known/keys`,
532
517
  response_types_supported: ['code'],
533
518
  scopes_supported: ['openid'],
@@ -556,6 +541,22 @@ function config(app, { showLoginPage, isStateless }) {
556
541
  // Omit authorization-info_endpoint for CP
557
542
  }
558
543
 
544
+ if (idp === 'singPass') {
545
+ data['scopes_supported'] = [
546
+ ...data['scopes_supported'],
547
+ 'uinfin',
548
+ 'name',
549
+ 'email',
550
+ 'mobileno',
551
+ 'regadd',
552
+ ]
553
+ data['userinfo_endpoint'] = `${baseUrl}/userinfo`
554
+ data['userinfo_signing_alg_values_supported'] = ['ES256']
555
+ data['userinfo_encryption_alg_values_supported'] =
556
+ userinfo_encryption_alg_values_supported[idp]
557
+ data['userinfo_encryption_enc_values_supported'] = ['A256GCM']
558
+ }
559
+
559
560
  res.status(200).send(data)
560
561
  },
561
562
  )
@@ -563,8 +564,199 @@ function config(app, { showLoginPage, isStateless }) {
563
564
  app.get(`/${idp.toLowerCase()}/v2/.well-known/keys`, (req, res) => {
564
565
  res.status(200).send(JSON.parse(aspPublic))
565
566
  })
567
+
568
+ app.get(`/${idp.toLowerCase()}/v2/userinfo`, async (req, res) => {
569
+ const { protocol, headers } = req
570
+ const host = req.get('host')
571
+
572
+ const authCode = extractBearerTokenFromHeader(headers, res)
573
+
574
+ const found = lookUpByAuthCode(authCode, {
575
+ isStateless,
576
+ })
577
+
578
+ if (!found || !found.scopes) {
579
+ return res.status(400).send({
580
+ error: 'invalid_request',
581
+ error_description:
582
+ 'Myinfo profile has yet to be provisioned for the user',
583
+ })
584
+ }
585
+
586
+ const {
587
+ profile: { uuid },
588
+ scopes,
589
+ clientId,
590
+ } = found
591
+ const scopesArr = scopes.split(' ')
592
+ console.log('userinfo scopes: ', scopesArr)
593
+
594
+ const profile = assertions.oidc.singPass.find((p) => p.uuid === uuid)
595
+
596
+ const iss = `${protocol}://${host}/${idp.toLowerCase()}/v2`
597
+ const aud = clientId
598
+
599
+ // Reuse the sub generation logic that was meant for ID token
600
+ const {
601
+ idTokenClaims: { sub, iat },
602
+ } = await assertions.oidc.create[idp](profile, iss, aud)
603
+
604
+ const userinfoPayload = {
605
+ sub,
606
+ iss,
607
+ aud,
608
+ iat,
609
+ }
610
+
611
+ const claimMap = profile.claims || {
612
+ uinfin: makeClaim(profile.nric),
613
+ name: makeClaim(`USER ${profile.nric}`),
614
+ }
615
+
616
+ for (const [claim, value] of Object.entries(claimMap)) {
617
+ if (scopesArr.includes(claim)) {
618
+ userinfoPayload[claim] = value
619
+ }
620
+ }
621
+
622
+ console.debug('userinfo payload:', userinfoPayload)
623
+
624
+ const rpKeysetJson = await fetchRpJwks({ idp, res })
625
+ const aspKeyset = JSON.parse(aspSecret)
626
+ const aspSigningKey = await getSigKey({ keySet: aspKeyset })
627
+
628
+ if (!aspSigningKey) {
629
+ console.error('No suitable signing key found', aspKeyset.keys)
630
+ return res.status(400).send({
631
+ error: 'invalid_request',
632
+ error_description: 'No suitable signing key found',
633
+ })
634
+ }
635
+
636
+ const signingKey = await jose.importJWK(aspSigningKey, 'ES256')
637
+ const signedProtectedHeader = {
638
+ alg: 'ES256',
639
+ typ: 'JWT',
640
+ kid: aspSigningKey.kid,
641
+ }
642
+
643
+ const textEncoder = new TextEncoder()
644
+ const userinfoJws = await new jose.CompactSign(
645
+ textEncoder.encode(JSON.stringify(userinfoPayload)),
646
+ )
647
+ .setProtectedHeader(signedProtectedHeader)
648
+ .sign(signingKey)
649
+
650
+ console.debug('userinfo JWS:', userinfoJws)
651
+
652
+ const rpEncryptionKey = findEncryptionKey(
653
+ rpKeysetJson,
654
+ userinfo_encryption_alg_values_supported[idp],
655
+ )
656
+
657
+ console.debug('rp encrypted key', rpEncryptionKey)
658
+
659
+ const encryptedProtectedHeader = {
660
+ alg: rpEncryptionKey.alg,
661
+ typ: 'JWT',
662
+ kid: rpEncryptionKey.kid,
663
+ enc: 'A256GCM',
664
+ cty: 'JWT',
665
+ }
666
+
667
+ const encryptionKey = await jose.importJWK(rpEncryptionKey, 'ES256')
668
+ const userinfoJwe = await new jose.CompactEncrypt(
669
+ textEncoder.encode(userinfoJws),
670
+ )
671
+ .setProtectedHeader(encryptedProtectedHeader)
672
+ .encrypt(encryptionKey)
673
+
674
+ console.debug('userinfo JWE:', userinfoJwe)
675
+
676
+ return res.status(200).type('application/jwt').send(userinfoJwe)
677
+ })
566
678
  }
679
+
567
680
  return app
568
681
  }
569
682
 
683
+ const fetchRpJwks = async ({ idp, res }) => {
684
+ const rpJwksEndpoint =
685
+ idp === 'singPass'
686
+ ? process.env.SP_RP_JWKS_ENDPOINT
687
+ : process.env.CP_RP_JWKS_ENDPOINT
688
+
689
+ let rpKeysetString
690
+
691
+ if (rpJwksEndpoint) {
692
+ try {
693
+ const rpKeysetResponse = await fetch(rpJwksEndpoint, {
694
+ method: 'GET',
695
+ })
696
+ rpKeysetString = await rpKeysetResponse.text()
697
+ if (!rpKeysetResponse.ok) {
698
+ throw new Error(rpKeysetString)
699
+ }
700
+ } catch (e) {
701
+ console.error('Failed to fetch RP JWKS from', rpJwksEndpoint, e.message)
702
+ return res.status(400).send({
703
+ error: 'invalid_client',
704
+ error_description: `Failed to fetch RP JWKS from specified endpoint: ${e.message}`,
705
+ })
706
+ }
707
+ } else {
708
+ // If the endpoint is not defined, default to the sample keyset we provided.
709
+ rpKeysetString = rpPublic
710
+ }
711
+
712
+ let rpKeysetJson
713
+ try {
714
+ rpKeysetJson = JSON.parse(rpKeysetString)
715
+ } catch (e) {
716
+ console.error('Unable to parse RP keyset', e.message)
717
+ return res.status(400).send({
718
+ error: 'invalid_client',
719
+ error_description: `Unable to parse RP keyset: ${e.message}`,
720
+ })
721
+ }
722
+
723
+ return rpKeysetJson
724
+ }
725
+
726
+ const getSigKey = async ({ keySet, kty = 'EC', crv = 'P-256' }) => {
727
+ const signingKey = keySet.keys.find(
728
+ (item) => item.use === 'sig' && item.kty === kty && item.crv === crv,
729
+ )
730
+
731
+ return signingKey
732
+ }
733
+
734
+ const extractBearerTokenFromHeader = (headers, res) => {
735
+ const authHeader = headers.authorization || headers.Authorization
736
+
737
+ if (!authHeader) {
738
+ return res.status(401).send({
739
+ error: 'invalid_request',
740
+ error_description: 'Missing Authorization header',
741
+ })
742
+ }
743
+
744
+ const tokenParts = authHeader.trim().split(' ')
745
+ if (tokenParts.length !== 2 || tokenParts[0] !== 'Bearer' || !tokenParts[1]) {
746
+ return res.status(401).send({
747
+ error: 'invalid_request',
748
+ error_description: 'Malformed Authorization header',
749
+ })
750
+ }
751
+
752
+ return tokenParts[1]
753
+ }
754
+
755
+ const makeClaim = (value) => ({
756
+ lastupdated: '2023-03-23',
757
+ source: '1',
758
+ classification: 'C',
759
+ value,
760
+ })
761
+
570
762
  module.exports = config
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengovsg/mockpass",
3
- "version": "4.4.3",
3
+ "version": "4.5.1",
4
4
  "description": "A mock SingPass/CorpPass server for dev purposes",
5
5
  "main": "app.js",
6
6
  "bin": {
@@ -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",