@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 +1 -1
- package/lib/auth-code.js +5 -5
- package/lib/express/oidc/v2-ndi.js +237 -56
- package/package.json +3 -3
package/lib/assertions.js
CHANGED
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(
|
|
13
|
-
|
|
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 (
|
|
170
|
+
if (typeof scopes !== 'string') {
|
|
160
171
|
return res.status(400).send({
|
|
161
172
|
error: 'invalid_scope',
|
|
162
|
-
error_description: `Unknown 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(
|
|
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(
|
|
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
|
|
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, {
|
|
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
|
|
440
|
-
|
|
441
|
-
|
|
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
|
|
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.
|
|
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": "^
|
|
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": "^
|
|
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",
|