@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 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/jwks.json
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 profiles = assertions.oidc['singPass']
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(`${FapiUtils.FAPI_PATH}/.well-known/keys`, (req, res) => {
16
+ app.get(`${fapiRoutesBasePath}/.well-known/keys`, (req, res) => {
15
17
  const keys = JSON.parse(
16
18
  fs.readFileSync(
17
- path.resolve(__dirname, '../../../static/certs/fapi-public.json'),
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
- `${FapiUtils.FAPI_PATH}/.well-known/openid-configuration`,
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(`${FapiUtils.FAPI_PATH}/par`, async (req, res) => {
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(`${FapiUtils.FAPI_PATH}/auth`, async (req, res) => {
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.post(`${FapiUtils.FAPI_PATH}/token`, async (req, res) => {
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(`${FapiUtils.FAPI_PATH}/tests/generate-tokens`, async (req, res) => {
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: `/${idp.toLowerCase()}/v2/auth/custom-profile`,
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 aspSecret = fs.readFileSync(
124
- path.resolve(__dirname, '../../../static/certs/oidc-v2-asp-secret.json'),
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 getSigKey = async ({ keySet, kty = 'EC', crv = 'P-256' }) => {
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: aspSigningKey.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, '../../../static/certs/fapi-public.json'),
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 FAPI_PATH = '/v3/fapi'
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')}${FAPI_PATH}`
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
- FAPI_PATH,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengovsg/mockpass",
3
- "version": "4.6.1",
3
+ "version": "4.6.3",
4
4
  "description": "A mock SingPass/CorpPass server for dev purposes",
5
5
  "main": "app.js",
6
6
  "bin": {
@@ -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}}