@opengovsg/mockpass 4.6.1 → 4.6.3
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 +3 -3
- package/lib/express/fapi/fapi.controller.js +20 -11
- package/lib/express/fapi/fapi.service.js +32 -22
- package/lib/express/fapi/utils.js +35 -9
- package/package.json +1 -1
- package/static/certs/fapi-asp-private.json +24 -0
- package/static/certs/fapi-asp-public.json +22 -0
- package/static/certs/{fapi-private.json → fapi-rp-private.json} +2 -1
- package/static/certs/{fapi-public.json → fapi-rp-public.json} +2 -2
- package/static/html/login-page.html +1 -0
package/README.md
CHANGED
|
@@ -21,14 +21,14 @@ For more information regarding the FAPI flow, refer to: https://docs.developer.s
|
|
|
21
21
|
|
|
22
22
|
Configure your endpoint to point to the following endpoints:
|
|
23
23
|
- http://localhost:5156/singpass/v3/fapi/.well-known/openid-configuration
|
|
24
|
-
- http://localhost:5156/singpass/v3/fapi/.well-known/
|
|
24
|
+
- http://localhost:5156/singpass/v3/fapi/.well-known/keys
|
|
25
25
|
- http://localhost:5156/singpass/v3/fapi/par
|
|
26
26
|
- http://localhost:5156/singpass/v3/fapi/auth
|
|
27
27
|
- http://localhost:5156/singpass/v3/fapi/token
|
|
28
28
|
|
|
29
29
|
|
|
30
|
-
In the `/fapi/utils.js file`, you can configure your client JWKS endpoint in the `fapiClientConfiguration`. By default, it is set to `null` and Mockpass will read the default keys that are stored in the `fapi-private.json` and `fapi-public.json` If configured, Mockpass will attempt to fetch the JWKS from the specified endpoint.
|
|
31
|
-
Your JWKS endpoint will need to be publicly accessible, and it needs to contain a valid JWKS with a sig key and an enc key.
|
|
30
|
+
In the `/fapi/utils.js file`, you can configure your client JWKS endpoint in the `fapiClientConfiguration`. By default, it is set to `null` and Mockpass will read the default keys that are stored in the `fapi-rp-private.json` and `fapi-rp-public.json` files. If configured, Mockpass will attempt to fetch the JWKS from the specified endpoint.
|
|
31
|
+
Your JWKS endpoint will need to be publicly accessible, and it needs to contain a valid JWKS with a `sig` key and an `enc` key.
|
|
32
32
|
|
|
33
33
|
Limitations:
|
|
34
34
|
- `client_id` and `redirect_uri` can be set to anything.
|
|
@@ -8,13 +8,15 @@ const path = require('path')
|
|
|
8
8
|
function config(app) {
|
|
9
9
|
app.use(express.json())
|
|
10
10
|
app.use(express.urlencoded({ extended: true }))
|
|
11
|
-
const
|
|
11
|
+
const idp = 'singPass'
|
|
12
|
+
const profiles = assertions.oidc[idp]
|
|
12
13
|
const service = new FapiService()
|
|
14
|
+
const fapiRoutesBasePath = FapiUtils.getFapiPath(idp.toLowerCase())
|
|
13
15
|
|
|
14
|
-
app.get(`${
|
|
16
|
+
app.get(`${fapiRoutesBasePath}/.well-known/keys`, (req, res) => {
|
|
15
17
|
const keys = JSON.parse(
|
|
16
18
|
fs.readFileSync(
|
|
17
|
-
path.resolve(__dirname,
|
|
19
|
+
path.resolve(__dirname, FapiUtils.FAPI_ASP_PUBLIC_JWKS_PATH),
|
|
18
20
|
'utf8',
|
|
19
21
|
),
|
|
20
22
|
)
|
|
@@ -22,13 +24,13 @@ function config(app) {
|
|
|
22
24
|
})
|
|
23
25
|
|
|
24
26
|
app.get(
|
|
25
|
-
`${
|
|
27
|
+
`${fapiRoutesBasePath}/.well-known/openid-configuration`,
|
|
26
28
|
(req, res) => {
|
|
27
29
|
return res.send(FapiUtils.getFapiOpenIdConfiguration(req))
|
|
28
30
|
},
|
|
29
31
|
)
|
|
30
32
|
|
|
31
|
-
app.post(`${
|
|
33
|
+
app.post(`${fapiRoutesBasePath}/par`, async (req, res) => {
|
|
32
34
|
try {
|
|
33
35
|
const request_uri = await service.handleParRequest(req)
|
|
34
36
|
return res.status(201).send({ request_uri, expires_in: 60 })
|
|
@@ -37,18 +39,25 @@ function config(app) {
|
|
|
37
39
|
}
|
|
38
40
|
})
|
|
39
41
|
|
|
40
|
-
app.get(`${
|
|
42
|
+
app.get(`${fapiRoutesBasePath}/auth`, async (req, res) => {
|
|
41
43
|
try {
|
|
42
44
|
const authRequest = await service.handleAuthorizationRequest(req)
|
|
43
|
-
return res.send(
|
|
44
|
-
service.generateLoginPage(profiles, 'singPass', authRequest),
|
|
45
|
-
)
|
|
45
|
+
return res.send(service.generateLoginPage(profiles, idp, authRequest))
|
|
46
46
|
} catch (e) {
|
|
47
47
|
return res.status(400).send({ error: e.message })
|
|
48
48
|
}
|
|
49
49
|
})
|
|
50
50
|
|
|
51
|
-
app.
|
|
51
|
+
app.get(`${fapiRoutesBasePath}/auth/custom-profile`, (req, res) => {
|
|
52
|
+
try {
|
|
53
|
+
const assertURL = service.handleCustomProfileAuthorizationRequest(req)
|
|
54
|
+
return res.redirect(assertURL)
|
|
55
|
+
} catch (e) {
|
|
56
|
+
return res.status(400).send({ error: e.message })
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
app.post(`${fapiRoutesBasePath}/token`, async (req, res) => {
|
|
52
61
|
try {
|
|
53
62
|
return res.status(200).send(await service.handleTokenRequest(req))
|
|
54
63
|
} catch (e) {
|
|
@@ -57,7 +66,7 @@ function config(app) {
|
|
|
57
66
|
})
|
|
58
67
|
|
|
59
68
|
//Helper function to generate DPoP proof JWT and client assertion token for testing. In real implementation, these tokens should be generated by the client.
|
|
60
|
-
app.post(`${
|
|
69
|
+
app.post(`${fapiRoutesBasePath}/tests/generate-tokens`, async (req, res) => {
|
|
61
70
|
try {
|
|
62
71
|
const { ephemeralPrivateKey, dpopToken, clientAssertionToken } =
|
|
63
72
|
await FapiUtils.generateDpopAndClientAssertionToken(req)
|
|
@@ -68,6 +68,20 @@ class FapiService {
|
|
|
68
68
|
return await this.generateIdToken(req, authRequest)
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
handleCustomProfileAuthorizationRequest(req) {
|
|
72
|
+
const { nric, uuid, request_uri } = req.query
|
|
73
|
+
assert(request_uri, 'Request URI is required')
|
|
74
|
+
assert(nric, 'NRIC is required')
|
|
75
|
+
assert(uuid, 'UUID is required')
|
|
76
|
+
|
|
77
|
+
const authRequest = this.map.get(request_uri)
|
|
78
|
+
if (!authRequest) throw new Error('No PAR request found in session')
|
|
79
|
+
|
|
80
|
+
const profile = { nric, uuid }
|
|
81
|
+
const authCode = generateAuthCodeForFapi({ profile, authRequest })
|
|
82
|
+
return buildAssertURL(authRequest.redirect_uri, authCode, authRequest.state)
|
|
83
|
+
}
|
|
84
|
+
|
|
71
85
|
generateLoginPage(profiles, idp, authRequest) {
|
|
72
86
|
const state = authRequest.state
|
|
73
87
|
const values = profiles.map((profile) => {
|
|
@@ -88,9 +102,12 @@ class FapiService {
|
|
|
88
102
|
return render(LOGIN_TEMPLATE, {
|
|
89
103
|
values,
|
|
90
104
|
customProfileConfig: {
|
|
91
|
-
endpoint:
|
|
105
|
+
endpoint: `${FapiUtils.getFapiPath(
|
|
106
|
+
idp.toLowerCase(),
|
|
107
|
+
)}/auth/custom-profile`,
|
|
92
108
|
showUuid: true,
|
|
93
109
|
showUen: idp === 'corpPass',
|
|
110
|
+
requestUri: authRequest.request_uri,
|
|
94
111
|
redirectURI: authRequest.redirect_uri,
|
|
95
112
|
state: authRequest.state,
|
|
96
113
|
nonce: authRequest.nonce,
|
|
@@ -119,28 +136,23 @@ class FapiService {
|
|
|
119
136
|
}
|
|
120
137
|
|
|
121
138
|
//Sign Id Token
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
|
|
139
|
+
const signingKid = FapiUtils.FAPI_ASP_SIGNING_KID
|
|
140
|
+
const fapiPrivateJwks = JSON.parse(
|
|
141
|
+
fs.readFileSync(
|
|
142
|
+
path.resolve(__dirname, FapiUtils.FAPI_ASP_PRIVATE_JWKS_PATH),
|
|
143
|
+
),
|
|
125
144
|
)
|
|
145
|
+
const fapiSigningPrivateKey = fapiPrivateJwks.keys.find(
|
|
146
|
+
(item) => item.use === 'sig' && item.kid === signingKid,
|
|
147
|
+
)
|
|
148
|
+
if (!fapiSigningPrivateKey)
|
|
149
|
+
throw new Error(`No signing key found for kid "${signingKid}"`)
|
|
126
150
|
|
|
127
|
-
const
|
|
128
|
-
return keySet.keys.find(
|
|
129
|
-
(item) => item.use === 'sig' && item.kty === kty && item.crv === crv,
|
|
130
|
-
)
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const aspKeyset = JSON.parse(aspSecret)
|
|
134
|
-
const aspSigningKey = await getSigKey({ keySet: aspKeyset })
|
|
135
|
-
if (!aspSigningKey) {
|
|
136
|
-
throw new Error('No suitable signing key found')
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const signingKey = await jose.importJWK(aspSigningKey, 'ES256')
|
|
151
|
+
const signingKey = await jose.importJWK(fapiSigningPrivateKey, 'ES256')
|
|
140
152
|
const signedProtectedHeader = {
|
|
141
153
|
alg: 'ES256',
|
|
142
154
|
typ: 'JWT',
|
|
143
|
-
kid:
|
|
155
|
+
kid: signingKid,
|
|
144
156
|
}
|
|
145
157
|
const signedIdToken = await new jose.CompactSign(
|
|
146
158
|
new TextEncoder().encode(JSON.stringify(id_token)),
|
|
@@ -159,7 +171,7 @@ class FapiService {
|
|
|
159
171
|
} else {
|
|
160
172
|
rpEncPublicKey = JSON.parse(
|
|
161
173
|
readFileSync(
|
|
162
|
-
path.resolve(__dirname,
|
|
174
|
+
path.resolve(__dirname, FapiUtils.FAPI_RP_PUBLIC_JWKS_PATH),
|
|
163
175
|
),
|
|
164
176
|
)['keys'].find((key) => key.use === 'enc')
|
|
165
177
|
}
|
|
@@ -249,9 +261,7 @@ async function verifyClientAssertion(req) {
|
|
|
249
261
|
)
|
|
250
262
|
else
|
|
251
263
|
jwks = JSON.parse(
|
|
252
|
-
readFileSync(
|
|
253
|
-
path.resolve(__dirname, '../../../static/certs/fapi-public.json'),
|
|
254
|
-
),
|
|
264
|
+
readFileSync(path.resolve(__dirname, FapiUtils.FAPI_RP_PUBLIC_JWKS_PATH)),
|
|
255
265
|
)
|
|
256
266
|
if (!jwks.keys) throw new Error('Unable to fetch client jwks')
|
|
257
267
|
|
|
@@ -4,6 +4,7 @@ const {
|
|
|
4
4
|
createPublicKey,
|
|
5
5
|
randomUUID,
|
|
6
6
|
} = require('crypto')
|
|
7
|
+
const assert = require('assert')
|
|
7
8
|
const jose = require('jose')
|
|
8
9
|
const { readFileSync } = require('fs')
|
|
9
10
|
const path = require('path')
|
|
@@ -51,7 +52,13 @@ const idTokenConfiguration = {
|
|
|
51
52
|
TOKEN_EXPIRY: 1800,
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
const
|
|
55
|
+
const FAPI_SUFFIX_PATH = '/v3/fapi'
|
|
56
|
+
const FAPI_ASP_SIGNING_KID = 'fapi-asp-sig-key-01'
|
|
57
|
+
const FAPI_ASP_ENCRYPTION_KID = 'fapi-asp-enc-key-01'
|
|
58
|
+
const FAPI_ASP_PRIVATE_JWKS_PATH = '../../../static/certs/fapi-asp-private.json'
|
|
59
|
+
const FAPI_ASP_PUBLIC_JWKS_PATH = '../../../static/certs/fapi-asp-public.json'
|
|
60
|
+
const FAPI_RP_PRIVATE_JWKS_PATH = '../../../static/certs/fapi-rp-private.json'
|
|
61
|
+
const FAPI_RP_PUBLIC_JWKS_PATH = '../../../static/certs/fapi-rp-public.json'
|
|
55
62
|
const fapiClientConfiguration = {
|
|
56
63
|
//This is only used for the /generate-tokens call. If used, your POST request must also use the same client_id
|
|
57
64
|
client_id: 'mock-fapi-client-id',
|
|
@@ -60,8 +67,21 @@ const fapiClientConfiguration = {
|
|
|
60
67
|
client_jwks: process.env.FAPI_CLIENT_JWKS_ENDPOINT || null,
|
|
61
68
|
}
|
|
62
69
|
|
|
70
|
+
function getFapiPath(idp) {
|
|
71
|
+
assert(idp, 'IDP is required')
|
|
72
|
+
return `/${String(idp).toLowerCase()}${FAPI_SUFFIX_PATH}`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function inferIdpFromRequest(req) {
|
|
76
|
+
const [, idp] = req.path.split('/')
|
|
77
|
+
assert(idp, 'Unable to infer IDP from request path')
|
|
78
|
+
return idp
|
|
79
|
+
}
|
|
80
|
+
|
|
63
81
|
function getIssuerFromRequest(req) {
|
|
64
|
-
return `${req.protocol}://${req.get('host')}${
|
|
82
|
+
return `${req.protocol}://${req.get('host')}${getFapiPath(
|
|
83
|
+
inferIdpFromRequest(req),
|
|
84
|
+
)}`
|
|
65
85
|
}
|
|
66
86
|
|
|
67
87
|
function getFapiOpenIdConfiguration(req) {
|
|
@@ -160,15 +180,15 @@ async function generateDpopToken(req, publicKey, privateKey) {
|
|
|
160
180
|
}
|
|
161
181
|
async function generateClientAssertionToken(req) {
|
|
162
182
|
const clientAssertionPrivateKey = JSON.parse(
|
|
163
|
-
readFileSync(
|
|
164
|
-
path.resolve(__dirname, '../../../static/certs/fapi-private.json'),
|
|
165
|
-
),
|
|
183
|
+
readFileSync(path.resolve(__dirname, FAPI_RP_PRIVATE_JWKS_PATH)),
|
|
166
184
|
)['keys'].find((key) => key.use === 'sig')
|
|
185
|
+
if (!clientAssertionPrivateKey)
|
|
186
|
+
throw new Error('No RP signing private key found')
|
|
167
187
|
const clientAssertionPublicKey = JSON.parse(
|
|
168
|
-
readFileSync(
|
|
169
|
-
path.resolve(__dirname, '../../../static/certs/fapi-public.json'),
|
|
170
|
-
),
|
|
188
|
+
readFileSync(path.resolve(__dirname, FAPI_RP_PUBLIC_JWKS_PATH)),
|
|
171
189
|
)['keys'].find((key) => key.use === 'sig')
|
|
190
|
+
if (!clientAssertionPublicKey)
|
|
191
|
+
throw new Error('No RP signing public key found')
|
|
172
192
|
const clientAssertion = {
|
|
173
193
|
headers: {
|
|
174
194
|
alg: 'ES256',
|
|
@@ -197,7 +217,13 @@ module.exports = {
|
|
|
197
217
|
AllowedHttpsRedirectTypes,
|
|
198
218
|
clientAssertionConfig,
|
|
199
219
|
dpopConfiguration,
|
|
200
|
-
|
|
220
|
+
FAPI_ASP_ENCRYPTION_KID,
|
|
221
|
+
FAPI_ASP_PRIVATE_JWKS_PATH,
|
|
222
|
+
FAPI_ASP_PUBLIC_JWKS_PATH,
|
|
223
|
+
FAPI_ASP_SIGNING_KID,
|
|
224
|
+
FAPI_RP_PRIVATE_JWKS_PATH,
|
|
225
|
+
FAPI_RP_PUBLIC_JWKS_PATH,
|
|
226
|
+
getFapiPath,
|
|
201
227
|
fapiClientConfiguration,
|
|
202
228
|
generateDpopAndClientAssertionToken,
|
|
203
229
|
getFapiOpenIdConfiguration,
|
package/package.json
CHANGED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"keys": [
|
|
3
|
+
{
|
|
4
|
+
"crv": "P-256",
|
|
5
|
+
"d": "i6SXSFXS82EvOj0vkS2V-4x6ItX79J9FLD79CsSLIks",
|
|
6
|
+
"kty": "EC",
|
|
7
|
+
"x": "6L_1vnXNH2IcAQIpTpaZ3LaxNby3j9t3YSQuWFmid8o",
|
|
8
|
+
"y": "OOTZ53oQxLJGZ0UR0jE4w_NSs7x_OCaz7GMcis53rK8",
|
|
9
|
+
"use": "sig",
|
|
10
|
+
"alg": "ES256",
|
|
11
|
+
"kid": "fapi-asp-sig-key-01"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"crv": "P-256",
|
|
15
|
+
"d": "8Kycerjx8N3TLI3a4sllkIET8ZrCyRG9ha3jJrup1gs",
|
|
16
|
+
"kty": "EC",
|
|
17
|
+
"x": "yVocAx9MRtU4n59kMmKNvQsri2gBxNX728ID8qMRiSw",
|
|
18
|
+
"y": "rB8rb1f2IfYD-ByQoa_EXsG2amrhXS8Asvn0Wv1z70c",
|
|
19
|
+
"use": "enc",
|
|
20
|
+
"alg": "ECDH-ES+A256KW",
|
|
21
|
+
"kid": "fapi-asp-enc-key-01"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"keys": [
|
|
3
|
+
{
|
|
4
|
+
"crv": "P-256",
|
|
5
|
+
"kty": "EC",
|
|
6
|
+
"x": "6L_1vnXNH2IcAQIpTpaZ3LaxNby3j9t3YSQuWFmid8o",
|
|
7
|
+
"y": "OOTZ53oQxLJGZ0UR0jE4w_NSs7x_OCaz7GMcis53rK8",
|
|
8
|
+
"use": "sig",
|
|
9
|
+
"alg": "ES256",
|
|
10
|
+
"kid": "fapi-asp-sig-key-01"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"crv": "P-256",
|
|
14
|
+
"kty": "EC",
|
|
15
|
+
"x": "yVocAx9MRtU4n59kMmKNvQsri2gBxNX728ID8qMRiSw",
|
|
16
|
+
"y": "rB8rb1f2IfYD-ByQoa_EXsG2amrhXS8Asvn0Wv1z70c",
|
|
17
|
+
"use": "enc",
|
|
18
|
+
"alg": "ECDH-ES+A256KW",
|
|
19
|
+
"kid": "fapi-asp-enc-key-01"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
"y": "elyS0xZn3ymk65tVPYO3pZplEaOEZtj_RegJ3_cwq7A",
|
|
7
7
|
"crv": "P-256",
|
|
8
8
|
"d": "uRwK14a2icjic0DSFsOG2PgKgqfZobaqhjgGS0wbkho",
|
|
9
|
+
"kid": "fapi-rp-sig-key-01",
|
|
9
10
|
"use": "sig",
|
|
10
11
|
"alg": "ES256"
|
|
11
12
|
},
|
|
@@ -17,7 +18,7 @@
|
|
|
17
18
|
"d": "wqtEFMSkoWeYqfZ-aqbSn5CE2KmgqWZgxrowWQaHCXA",
|
|
18
19
|
"use": "enc",
|
|
19
20
|
"alg": "ECDH-ES+A256KW",
|
|
20
|
-
"kid": "fapi-enc-key"
|
|
21
|
+
"kid": "fapi-rp-enc-key-01"
|
|
21
22
|
}
|
|
22
23
|
]
|
|
23
24
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"crv": "P-256",
|
|
6
6
|
"x": "tkRXMLpC7djlH7cqntg8-fuekxG9YTvJx8IsRKApcAg",
|
|
7
7
|
"y": "elyS0xZn3ymk65tVPYO3pZplEaOEZtj_RegJ3_cwq7A",
|
|
8
|
-
"kid": "fapi-key-01",
|
|
8
|
+
"kid": "fapi-rp-sig-key-01",
|
|
9
9
|
"use": "sig",
|
|
10
10
|
"alg": "ES256"
|
|
11
11
|
},
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"crv": "P-256",
|
|
17
17
|
"use": "enc",
|
|
18
18
|
"alg": "ECDH-ES+A256KW",
|
|
19
|
-
"kid": "fapi-enc-key"
|
|
19
|
+
"kid": "fapi-rp-enc-key-01"
|
|
20
20
|
}
|
|
21
21
|
]
|
|
22
22
|
}
|
|
@@ -198,6 +198,7 @@
|
|
|
198
198
|
{{/showUen}}
|
|
199
199
|
|
|
200
200
|
{{#assertEndpoint}}<input type="hidden" name="assertEndpoint" value="{{ assertEndpoint }}" />{{/assertEndpoint}}
|
|
201
|
+
{{#requestUri}}<input type="hidden" name="request_uri" value="{{ requestUri }}" />{{/requestUri}}
|
|
201
202
|
{{#redirectURI}}<input type="hidden" name="redirectURI" value="{{ redirectURI }}" />{{/redirectURI}}
|
|
202
203
|
{{#relayState}}<input type="hidden" name="relayState" value="{{ relayState }}" />{{/relayState}}
|
|
203
204
|
{{#state}}<input type="hidden" name="state" value="{{ state }}" />{{/state}}
|