@opengovsg/mockpass 4.5.7 → 4.6.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/README.md CHANGED
@@ -15,6 +15,46 @@ A mock Singpass/Corppass/Myinfo v3/sgID v2/Sign v3 server for dev purposes
15
15
 
16
16
  ## Quick Start (hosted locally)
17
17
 
18
+ ### Singpass v3 (FAPI flow)
19
+
20
+ For more information regarding the FAPI flow, refer to: https://docs.developer.singpass.gov.sg/docs/technical-specifications/integration-guide/1.-authorization-request
21
+
22
+ Configure your endpoint to point to the following endpoints:
23
+ - http://localhost:5156/singpass/v3/fapi/.well-known/openid-configuration
24
+ - http://localhost:5156/singpass/v3/fapi/.well-known/jwks.json
25
+ - http://localhost:5156/singpass/v3/fapi/par
26
+ - http://localhost:5156/singpass/v3/fapi/auth
27
+ - http://localhost:5156/singpass/v3/fapi/token
28
+
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.
32
+
33
+ Limitations:
34
+ - `client_id` and `redirect_uri` can be set to anything.
35
+ - Mockpass will not check if ephemeral keys, state, and nonce are reused.
36
+ - Only Login is supported for now. Userinfo endpoint is not supported.
37
+ - Only `openid` is supported for the `scope` parameter.
38
+ - Only `urn:singpass:authentication:loa:1` is supported for the `acr_values` parameter.
39
+
40
+ ### Helper functions
41
+
42
+ There is a helper endpoint that can generate the ephemeral keys and tokens for you. This is useful if you want to experience the FAPI flow without a server setup.
43
+ - POST: http://localhost:5156/singpass/v3/fapi/tests/generate-tokens
44
+ #### Request Body
45
+
46
+ | Body | Description | Example |
47
+ |---------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------|
48
+ | ephemeralPrivateKey | Optional. This is the generated ephemeral private key that will be used for the auth session. If provided, it will be used to sign the DPoP tokens. Otherwise, a new key will be generated for you in the response body. <br/><br/> Note: If you are calling the /token endpoint, you will need to pass in the generated private key so that the same key is used for signing the DPoP token. | ``` -----BEGIN PRIVATE KEY-----\nMIGHAgE...3HMe8M82x\n-----END PRIVATE KEY----- ``` |
49
+ | endpoint | Mandatory. This is to populate the htu parameter in the DPoP token. Depending on which endpoint you are calling in the FAPI flow, you should select the correct endpoint. | Possible values are: `http://localhost:5156/singpass/v3/fapi/par`, `http://localhost:5156/singpass/v3/fapi/token` |
50
+
51
+ #### Response Body
52
+ | Body | Description | Type |
53
+ |----------------------|---------------------------------------------------------------------------------------------------------------------|------------|
54
+ | dpopToken | The dpop token that is used for the API call. | jwt |
55
+ | clientAssertionToken | The client assertion token that is used for the API call. | jwt |
56
+ | ephemeralPrivateKey | The ephemeral private key that is used to sign the dpop token. This key should be used for the entire auth session. | PEM format |
57
+
18
58
  ### Singpass v2 (NDI OIDC)
19
59
 
20
60
  Configure your application to point to the following endpoints:
@@ -34,7 +74,7 @@ Configure your application (or MockPass) with keys:
34
74
  MockPass accepts any value for `client_id` and `redirect_uri`.
35
75
 
36
76
  | Configuration item | Explanation |
37
- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
77
+ |------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
38
78
  | Client signing and encryption keys | **Overview:** When client makes any request, what signing key is used to verify the client's signature on the client assertion, and what encryption key is used to encrypt the data payload. <br> **Default:** static keyset `static/certs/oidc-v2-rp-public.json` is used. <br> **How to configure:** Set the env var `SP_RP_JWKS_ENDPOINT` to a JWKS URL that MockPass can connect to. This can be a HTTP or HTTPS URL. |
39
79
  | Login page | **Overview:** When client makes an authorize request, whether MockPass sends the client to a login page, instead of completing the login silently. <br> **Default:** Disabled for all requests. <br> **How to configure:** Enable for all requests by default by setting the env var `SHOW_LOGIN_PAGE` to `true`. Regardless of the default, you can override on a per-request basis by sending the HTTP request header `X-Show-Login-Page` with the value `true`. <br> **Detailed effect:** When login page is disabled, MockPass will immediately complete login and redirect to the `redirect_uri`. The profile used will be (in order of decreasing precedence) the profile specified in HTTP request headers (`X-Custom-NRIC` and `X-Custom-UUID` must both be specified), the profile with the NRIC specified in the env var `MOCKPASS_NRIC`, or the first profile in MockPass' static data. <br> When login page is enabled, MockPass returns a HTML page with a form that is used to complete the login. The client may select an existing profile, or provide a custom NRIC and UUID on the form. |
40
80
  | ID token exchange | **Overview:** Singpass uses the client's [profile](https://docs.developer.singpass.gov.sg/docs/technical-specifications/singpass-authentication-api/2.-token-endpoint/authorization-code-grant#id-token-structure) to decide the format of the id token to send across. <br> **Default:** `direct` <br> **How to configure:** To set this, set the env var (`SINGPASS_CLIENT_PROFILE`) to the desired value |
@@ -58,7 +98,7 @@ Configure your application (or MockPass) with keys:
58
98
  MockPass accepts any value for `client_id` and `redirect_uri`.
59
99
 
60
100
  | Configuration item | Explanation |
61
- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
101
+ |------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
62
102
  | Client signing and encryption keys | **Overview:** When client makes any request, what signing key is used to verify the client's signature on the client assertion, and what encryption key is used to encrypt the data payload. <br> **Default:** static keyset `static/certs/oidc-v2-rp-public.json` is used. <br> **How to configure:** Set the env var `CP_RP_JWKS_ENDPOINT` to a JWKS URL that MockPass can connect to. This can be a HTTP or HTTPS URL. |
63
103
  | Login page | **Overview:** When client makes an authorize request, whether MockPass sends the client to a login page, instead of completing the login silently. <br> **Default:** Disabled for all requests. <br> **How to configure:** Enable for all requests by default by setting the env var `SHOW_LOGIN_PAGE` to `true`. Regardless of the default, you can override on a per-request basis by sending the HTTP request header `X-Show-Login-Page` with the value `true`. <br> **Detailed effect:** When login page is disabled, MockPass will immediately complete login and redirect to the `redirect_uri`. The profile used will be (in order of decreasing precedence) the profile specified in HTTP request headers (`X-Custom-NRIC`, `X-Custom-UUID`, `X-Custom-UEN` must all be specified), the profile with the NRIC specified in the env var `MOCKPASS_NRIC`, or the first profile in MockPass' static data. <br> When login page is enabled, MockPass returns a HTML page with a form that is used to complete the login. The client may select an existing profile, or provide a custom NRIC, UUID and UEN on the form. |
64
104
 
@@ -90,7 +130,7 @@ succeed, using other NRICs will result in an error. See the list of personas in
90
130
  [static/myinfo/v3.json](static/myinfo/v3.json).
91
131
 
92
132
  | Configuration item | Explanation |
93
- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
133
+ |--------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
94
134
  | Client certificate | **Overview:** When client makes any request, what certificate is used to verify the request signature, and what certificate is used to encrypt the data payload. <br> **Default:** static certificate/key `static/certs/(server.crt\|key.pub)` are used. <br> **How to configure:** Set the env var `SERVICE_PROVIDER_PUB_KEY` to the path to a public key PEM file, and `SERVICE_PROVIDER_CERT_PATH` to the path to a certificate PEM file. (A certificate PEM file can also be provided to `SERVICE_PROVIDER_PUB_KEY`, despite the env var name.) |
95
135
  | Client secret | **Overview:** When client makes a Token request, whether MockPass verifies the request signature. <br> **Default:** Disabled. <br> **How to configure:** Enable for all requests by setting the env var `SERVICE_PROVIDER_MYINFO_SECRET` to some non-blank string. Provide this value to your application as well. |
96
136
  | Payload encryption | **Overview:** When client makes a Person or Person-Basic request, whether MockPass encrypts the data payload. When client makes a Person request, whether MockPass verifies the request signature. <br> **Default:** Disabled. <br> **How to configure:** Enable for all requests by setting the env var `ENCRYPT_MYINFO` to `true`. |
@@ -134,7 +174,7 @@ identify them.
134
174
  The `pocdex.number_of_employments` scope is not supported.
135
175
 
136
176
  | Configuration item | Explanation |
137
- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
177
+ |--------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
138
178
  | Client certificate | **Overview:** When client makes any request, what certificate is used to verify the request signature, and what certificate is used to encrypt the data payload. <br> **Default:** static key `static/certs/key.pub` is used. <br> **How to configure:** Set the env var `SERVICE_PROVIDER_PUB_KEY` to the path to a public key PEM file. (A certificate PEM file can also be provided, despite the env var name.) |
139
179
  | Login page | **Overview:** When client makes an authorize request, whether MockPass sends the client to a login page, instead of completing the login silently. <br> **Default:** Disabled for all requests. <br> **How to configure:** Enable for all requests by default by setting the env var `SHOW_LOGIN_PAGE` to `true`. Regardless of the default, you can override on a per-request basis by sending the HTTP request header `X-Show-Login-Page` with the value `true`. <br> **Detailed effect:** When login page is disabled, MockPass will immediately complete login and redirect to the `redirect_uri`. The profile used will be (in order of decreasing precedence) the profile with the NRIC specified in the env var `MOCKPASS_NRIC`, or the first profile in MockPass' static data. <br> When login page is enabled, MockPass returns a HTML page with a form that is used to complete the login. The client may select an existing profile, or provide a custom NRIC and UUID on the form. |
140
180
 
@@ -186,7 +226,7 @@ Configure MockPass with your application client details:
186
226
  Common configuration:
187
227
 
188
228
  | Configuration item | Explanation |
189
- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
229
+ |--------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
190
230
  | Port number | **Overview:** What port number MockPass will listen for HTTP requests on. <br> **Default:** 5156. <br> **How to configure:** Set the env var `MOCKPASS_PORT` or `PORT` to some port number. |
191
231
  | Stateless Mode | **Overview:** Enable for environments where the state of the process is not guaranteed, such as in serverless contexts. <br> **Default:** not set. <br> **How to configure:** Set the env var `MOCKPASS_STATELESS` to `true` or `false`. |
192
232
 
package/app.js CHANGED
@@ -11,6 +11,7 @@ const {
11
11
  configMyInfo,
12
12
  configSGID,
13
13
  configSignV3,
14
+ configFapi,
14
15
  } = require('./lib/express')
15
16
 
16
17
  const serviceProvider = {
@@ -54,6 +55,7 @@ configOIDC(app, options)
54
55
  configOIDCv2(app, options)
55
56
  configSGID(app, options)
56
57
  configSignV3(app, options)
58
+ configFapi(app, options)
57
59
 
58
60
  configMyInfo.consent(app, options)
59
61
  configMyInfo.v3(app, options)
package/lib/auth-code.js CHANGED
@@ -18,10 +18,17 @@ const generateAuthCode = (
18
18
  return authCode
19
19
  }
20
20
 
21
+ const generateAuthCodeForFapi = ({ profile, clientId = '' }) => {
22
+ const authCode = crypto.randomBytes(45).toString('base64url')
23
+
24
+ profileAndNonceStore.set(authCode, { profile, clientId })
25
+ return authCode
26
+ }
27
+
21
28
  const lookUpByAuthCode = (authCode, { isStateless = false }) => {
22
29
  return isStateless
23
30
  ? JSON.parse(Buffer.from(authCode, 'base64url').toString('utf-8'))
24
31
  : profileAndNonceStore.get(authCode)
25
32
  }
26
33
 
27
- module.exports = { generateAuthCode, lookUpByAuthCode }
34
+ module.exports = { generateAuthCode, generateAuthCodeForFapi, lookUpByAuthCode }
@@ -0,0 +1,75 @@
1
+ const FapiService = require('./fapi.service.js')
2
+ const FapiUtils = require('./utils.js')
3
+ const express = require('express')
4
+ const assertions = require('../../assertions')
5
+ const fs = require('fs')
6
+ const path = require('path')
7
+
8
+ function config(app) {
9
+ app.use(express.json())
10
+ app.use(express.urlencoded({ extended: true }))
11
+ const profiles = assertions.oidc['singPass']
12
+ const service = new FapiService()
13
+
14
+ app.get(`${FapiUtils.FAPI_PATH}/.well-known/keys`, (req, res) => {
15
+ const keys = JSON.parse(
16
+ fs.readFileSync(
17
+ path.resolve(__dirname, '../../../static/certs/fapi-public.json'),
18
+ 'utf8',
19
+ ),
20
+ )
21
+ return res.send(keys)
22
+ })
23
+
24
+ app.get(
25
+ `${FapiUtils.FAPI_PATH}/.well-known/openid-configuration`,
26
+ (req, res) => {
27
+ return res.send(FapiUtils.getFapiOpenIdConfiguration(req))
28
+ },
29
+ )
30
+
31
+ app.post(`${FapiUtils.FAPI_PATH}/par`, async (req, res) => {
32
+ try {
33
+ const request_uri = await service.handleParRequest(req)
34
+ return res.status(201).send({ request_uri, expires_in: 60 })
35
+ } catch (e) {
36
+ return res.status(400).send({ error: e.message })
37
+ }
38
+ })
39
+
40
+ app.get(`${FapiUtils.FAPI_PATH}/auth`, async (req, res) => {
41
+ try {
42
+ const authRequest = await service.handleAuthorizationRequest(req)
43
+ return res.send(
44
+ service.generateLoginPage(profiles, 'singPass', authRequest),
45
+ )
46
+ } catch (e) {
47
+ return res.status(400).send({ error: e.message })
48
+ }
49
+ })
50
+
51
+ app.post(`${FapiUtils.FAPI_PATH}/token`, async (req, res) => {
52
+ try {
53
+ return res.status(200).send(await service.handleTokenRequest(req))
54
+ } catch (e) {
55
+ return res.status(400).send({ error: e.message })
56
+ }
57
+ })
58
+
59
+ //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) => {
61
+ try {
62
+ const { ephemeralPrivateKey, dpopToken, clientAssertionToken } =
63
+ await FapiUtils.generateDpopAndClientAssertionToken(req)
64
+ return res.send({
65
+ dpopToken,
66
+ clientAssertionToken,
67
+ ephemeralPrivateKey,
68
+ })
69
+ } catch (e) {
70
+ return res.status(400).send({ error: e.message })
71
+ }
72
+ })
73
+ }
74
+
75
+ module.exports = config
@@ -0,0 +1,418 @@
1
+ const assert = require('assert')
2
+ const crypto = require('crypto')
3
+ const FapiUtils = require('./utils.js')
4
+ const { readFileSync } = require('fs')
5
+ const path = require('path')
6
+ const jose = require('jose')
7
+ const { lookUpByAuthCode, generateAuthCodeForFapi } = require('../../auth-code')
8
+ const fs = require('fs')
9
+ const { buildAssertURL, idGenerator } = require('../oidc/utils')
10
+ const { render } = require('mustache')
11
+ const ExpiryMap = require('expiry-map')
12
+
13
+ class FapiService {
14
+ constructor() {
15
+ this.map = new ExpiryMap(5 * 60 * 1000) // PAR request expires in 5 minutes
16
+ }
17
+
18
+ async handleParRequest(req) {
19
+ verifyParRequestBody(req)
20
+ const parEndpoint =
21
+ FapiUtils.getFapiOpenIdConfiguration(
22
+ req,
23
+ ).pushed_authorization_request_endpoint
24
+ const [, dpopJkt] = await Promise.all([
25
+ verifyClientAssertion(req),
26
+ verifyDpop(req.headers['dpop'], null, parEndpoint),
27
+ ])
28
+
29
+ const request_uri = `urn:ietf:params:oauth:request_uri:${crypto
30
+ .randomBytes(64)
31
+ .toString('base64url')}`
32
+
33
+ const object = {
34
+ request_uri,
35
+ redirect_uri: req.body.redirect_uri,
36
+ client_id: req.body.client_id,
37
+ code_challenge: req.body.code_challenge,
38
+ code_challenge_method: req.body.code_challenge_method,
39
+ scope: req.body.scope,
40
+ nonce: req.body.nonce,
41
+ state: req.body.state,
42
+ dpopJkt,
43
+ }
44
+
45
+ this.map.set(req.body.client_id, object)
46
+
47
+ return request_uri
48
+ }
49
+
50
+ async handleAuthorizationRequest(req) {
51
+ const authRequest = this.map.get(req.query.client_id)
52
+ if (!authRequest) throw new Error('No PAR request found in session')
53
+ verifyAuthRequestBody(req, authRequest)
54
+ return authRequest
55
+ }
56
+
57
+ async handleTokenRequest(req) {
58
+ //Due to Mockpass limitations, we are using clientid for session management.
59
+ const client_id = await verifyClientAssertion(req)
60
+ const authRequest = this.map.get(client_id)
61
+ if (!authRequest) throw new Error('No auth request found in session')
62
+
63
+ verifyTokenRequestBody(req, authRequest)
64
+ const tokenEndpoint =
65
+ FapiUtils.getFapiOpenIdConfiguration(req).token_endpoint
66
+ await verifyDpop(req.headers['dpop'], authRequest.dpopJkt, tokenEndpoint)
67
+ return await this.generateIdToken(req, authRequest)
68
+ }
69
+
70
+ generateLoginPage(profiles, idp, authRequest) {
71
+ const state = authRequest.state
72
+ const values = profiles.map((profile) => {
73
+ const authCode = generateAuthCodeForFapi({ profile, authRequest })
74
+ const assertURL = buildAssertURL(
75
+ authRequest.redirect_uri,
76
+ authCode,
77
+ state,
78
+ )
79
+ const id = idGenerator[idp](profile)
80
+ return { id, assertURL }
81
+ })
82
+ const LOGIN_TEMPLATE = fs.readFileSync(
83
+ path.resolve(__dirname, '../../../static/html/login-page.html'),
84
+ 'utf8',
85
+ )
86
+ // console.debug('values: ', values) //useful when you want to test without a RP system
87
+ return render(LOGIN_TEMPLATE, {
88
+ values,
89
+ customProfileConfig: {
90
+ endpoint: `/${idp.toLowerCase()}/v2/auth/custom-profile`,
91
+ showUuid: true,
92
+ showUen: idp === 'corpPass',
93
+ redirectURI: authRequest.redirect_uri,
94
+ state: authRequest.state,
95
+ nonce: authRequest.nonce,
96
+ },
97
+ })
98
+ }
99
+
100
+ async generateIdToken(req, authRequest) {
101
+ //Generate tokens
102
+ const expires_in = FapiUtils.idTokenConfiguration.TOKEN_EXPIRY //30 minutes
103
+ const { profile } = lookUpByAuthCode(req.body.code, { isStateless: false })
104
+ if (!profile) throw new Error('No profile found in session')
105
+ const id_token = {
106
+ sub: profile.uuid,
107
+ sub_attributes: {
108
+ identity_number: profile.nric,
109
+ },
110
+ aud: authRequest.client_id,
111
+ acr: 'urn:singpass:authentication:loa:1', //Mockpass only
112
+ sub_type: 'user',
113
+ amr: ['pwd'],
114
+ iss: FapiUtils.getFapiOpenIdConfiguration(req).issuer,
115
+ exp: Math.floor(Date.now() / 1000) + expires_in,
116
+ iat: Date.now() / 1000,
117
+ nonce: authRequest.nonce,
118
+ }
119
+
120
+ //Sign Id Token
121
+
122
+ const aspSecret = fs.readFileSync(
123
+ path.resolve(__dirname, '../../../static/certs/oidc-v2-asp-secret.json'),
124
+ )
125
+
126
+ const getSigKey = async ({ keySet, kty = 'EC', crv = 'P-256' }) => {
127
+ return keySet.keys.find(
128
+ (item) => item.use === 'sig' && item.kty === kty && item.crv === crv,
129
+ )
130
+ }
131
+
132
+ const aspKeyset = JSON.parse(aspSecret)
133
+ const aspSigningKey = await getSigKey({ keySet: aspKeyset })
134
+ if (!aspSigningKey) {
135
+ throw new Error('No suitable signing key found')
136
+ }
137
+
138
+ const signingKey = await jose.importJWK(aspSigningKey, 'ES256')
139
+ const signedProtectedHeader = {
140
+ alg: 'ES256',
141
+ typ: 'JWT',
142
+ kid: aspSigningKey.kid,
143
+ }
144
+ const signedIdToken = await new jose.CompactSign(
145
+ new TextEncoder().encode(JSON.stringify(id_token)),
146
+ )
147
+ .setProtectedHeader(signedProtectedHeader)
148
+ .sign(signingKey)
149
+
150
+ //Encrypt signed id token
151
+ let rpEncPublicKey
152
+ if (FapiUtils.fapiClientConfiguration.client_jwks) {
153
+ const jwks = await fetch(
154
+ FapiUtils.fapiClientConfiguration.client_jwks,
155
+ ).then((res) => res.json())
156
+ if (!jwks.keys) throw new Error('Unable to fetch client jwks')
157
+ rpEncPublicKey = jwks.keys.find((key) => key.use === 'enc')
158
+ } else {
159
+ rpEncPublicKey = JSON.parse(
160
+ readFileSync(
161
+ path.resolve(__dirname, '../../../static/certs/fapi-public.json'),
162
+ ),
163
+ )['keys'].find((key) => key.use === 'enc')
164
+ }
165
+ if (!rpEncPublicKey) throw new Error('No suitable encryption key found')
166
+ const encKey = await jose.importJWK(rpEncPublicKey)
167
+ const encrpytionProtectedHeader = {
168
+ alg: rpEncPublicKey.alg,
169
+ typ: 'JWT',
170
+ kid: rpEncPublicKey.kid,
171
+ enc: 'A256GCM',
172
+ cty: 'JWT',
173
+ }
174
+ const encryptedIdToken = await new jose.CompactEncrypt(
175
+ new TextEncoder().encode(signedIdToken),
176
+ )
177
+ .setProtectedHeader(encrpytionProtectedHeader)
178
+ .encrypt(encKey)
179
+
180
+ return {
181
+ access_token: crypto.randomBytes(64).toString('base64url'),
182
+ id_token: encryptedIdToken,
183
+ token_type: 'DPoP',
184
+ }
185
+ }
186
+ }
187
+
188
+ //Verification functions
189
+ async function verifyDpop(dpop, dpop_jkt, expectedEndpoint) {
190
+ if (!dpop && !dpop_jkt) throw new Error('Dpop or Dpop jkt is required')
191
+ if (dpop) {
192
+ const dpopToken = dpop.split(' ')[0]
193
+ const [headerB64] = dpopToken.split('.')
194
+ const header = JSON.parse(Buffer.from(headerB64, 'base64').toString())
195
+ const jwk = header.jwk
196
+ if (!jwk) throw new Error('DPoP header missing jwk')
197
+ try {
198
+ const key = await jose.importJWK(jwk, 'ES256')
199
+ await jose.jwtVerify(dpopToken, key, {
200
+ clockTolerance: FapiUtils.dpopConfiguration.DPOP_CLOCK_SKEW,
201
+ requiredClaims: ['jti', 'iat', 'exp', 'htu', 'htm'],
202
+ })
203
+ } catch (error) {
204
+ throw new Error(`Invalid DPoP token signature, ${error.message}`)
205
+ }
206
+ // Extract payload from DPoP token
207
+ const payloadB64 = dpopToken.split('.')[1]
208
+ const payload = JSON.parse(Buffer.from(payloadB64, 'base64').toString())
209
+ const { htu, htm } = payload
210
+ if (htu.toLowerCase() !== expectedEndpoint.toLowerCase()) {
211
+ throw new Error('Invalid DPoP htu')
212
+ }
213
+ if (typeof htm !== 'string' || !htm) {
214
+ throw new Error('DPoP htm must be a non-empty string')
215
+ }
216
+ if (htm.toUpperCase() !== 'POST') {
217
+ throw new Error('Only POST requests are supported with DPoP')
218
+ }
219
+ const jwkThumbprint = crypto
220
+ .createHash('sha256')
221
+ .update(
222
+ JSON.stringify({
223
+ crv: jwk.crv,
224
+ kty: jwk.kty,
225
+ x: jwk.x,
226
+ y: jwk.y,
227
+ }),
228
+ )
229
+ .digest('base64url')
230
+ if (dpop_jkt && dpop_jkt !== jwkThumbprint)
231
+ throw new Error('Invalid DPoP jkt')
232
+ return jwkThumbprint
233
+ }
234
+ }
235
+ async function verifyClientAssertion(req) {
236
+ const { client_assertion, client_assertion_type } = req.body
237
+ assert(client_assertion, 'Client assertion is required')
238
+ assert(client_assertion_type, 'Client assertion type is required')
239
+ if (
240
+ client_assertion_type !==
241
+ 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
242
+ )
243
+ throw new Error('Invalid client assertion type')
244
+ let jwks
245
+ if (FapiUtils.fapiClientConfiguration.client_jwks)
246
+ jwks = await fetch(FapiUtils.fapiClientConfiguration.client_jwks).then(
247
+ (res) => res.json(),
248
+ )
249
+ else
250
+ jwks = JSON.parse(
251
+ readFileSync(
252
+ path.resolve(__dirname, '../../../static/certs/fapi-public.json'),
253
+ ),
254
+ )
255
+ if (!jwks.keys) throw new Error('Unable to fetch client jwks')
256
+
257
+ const [headerB64] = client_assertion.split('.')
258
+ const header = JSON.parse(Buffer.from(headerB64, 'base64').toString())
259
+ if (!header.kid) throw new Error('No kid found in client assertion header')
260
+
261
+ const keyMap = Object.fromEntries(jwks.keys.map((k) => [k.kid, k]))
262
+ const keyToTry = await jose.importJWK(keyMap[header.kid], 'ES256')
263
+ if (!keyToTry) throw new Error('No matching key found for kid')
264
+
265
+ const { payload } = await jose
266
+ .jwtVerify(client_assertion, keyToTry, {
267
+ algorithms: ['ES256'],
268
+ clockTolerance: FapiUtils.dpopConfiguration.DPOP_CLOCK_SKEW,
269
+ requiredClaims: ['iss', 'sub', 'aud', 'exp', 'iat', 'jti'],
270
+ })
271
+ .catch(() => {
272
+ throw new Error('Invalid client assertion signature')
273
+ })
274
+
275
+ if (payload.aud !== FapiUtils.getFapiOpenIdConfiguration(req).issuer)
276
+ throw new Error('Invalid client assertion aud')
277
+ return payload.iss
278
+ }
279
+ function verifyParRequestBody(req) {
280
+ const {
281
+ response_type,
282
+ scope,
283
+ state,
284
+ nonce,
285
+ client_id,
286
+ redirect_uri,
287
+ acr_values,
288
+ code_challenge,
289
+ code_challenge_method,
290
+ authentication_context_type,
291
+ authentication_context_message,
292
+ redirect_uri_https_type,
293
+ } = req.body
294
+
295
+ verifyClientInfo(client_id, redirect_uri, redirect_uri_https_type)
296
+ verifyStateAndNonce(state, nonce)
297
+ verifyAllowedScopes(scope)
298
+ verifyAcrValues(acr_values)
299
+ verifyResponseType(response_type)
300
+ verifyCodeChallenge(code_challenge, code_challenge_method)
301
+ verifyAuthenticationContext(
302
+ authentication_context_type,
303
+ authentication_context_message,
304
+ )
305
+ }
306
+ function verifyAuthRequestBody(req, parRequest) {
307
+ const { client_id, request_uri } = req.query
308
+ if (!client_id) throw new Error('No Client ID in query')
309
+ if (!request_uri) throw new Error('No Request URI in query')
310
+ if (request_uri !== parRequest.request_uri)
311
+ throw new Error('Request URI not found')
312
+ if (client_id !== parRequest.client_id)
313
+ throw new Error('Client ID does not match')
314
+ if (!parRequest.scope.includes('openid'))
315
+ throw new Error('Scope must include openid')
316
+ }
317
+ function verifyTokenRequestBody(req, authRequest) {
318
+ const { grant_type, code, redirect_uri, code_verifier } = req.body
319
+ assert(
320
+ FapiUtils.SupportedGrantTypes.AUTHORIZATION_CODE.includes(grant_type),
321
+ 'Only authorization code grant type is supported',
322
+ )
323
+ assert(code, 'Authorization code is required')
324
+ assert(redirect_uri, 'Redirect URI is required')
325
+ if (redirect_uri !== authRequest.redirect_uri)
326
+ throw new Error('Redirect URI does not match')
327
+ verifyCodeVerifier(code_verifier, authRequest.code_challenge)
328
+ }
329
+ function verifyClientInfo(clientId, redirect_uri, redirect_uri_https_type) {
330
+ /**
331
+ * Since this is a demo, will not enforce client id and redirect uri must match, but must be provided.
332
+ */
333
+ assert(clientId, 'Client ID is required')
334
+ assert(redirect_uri, 'Redirect URI is required')
335
+ let parsedUri
336
+ try {
337
+ parsedUri = new URL(redirect_uri)
338
+ } catch {
339
+ throw new Error('Invalid redirect uri')
340
+ }
341
+ const { protocol } = parsedUri
342
+ if (protocol !== 'https:' && protocol !== 'http:')
343
+ throw new Error('Invalid redirect uri protocol')
344
+ if (
345
+ redirect_uri_https_type &&
346
+ !FapiUtils.AllowedHttpsRedirectTypes.includes(redirect_uri_https_type)
347
+ )
348
+ throw new Error('Invalid redirect uri https type')
349
+ }
350
+ function verifyAllowedScopes(scope) {
351
+ assert(scope, 'Scope is required')
352
+ const scopes = scope.split(' ')
353
+ for (const s of scopes) {
354
+ if (!Object.values(FapiUtils.SupportedScope).includes(s))
355
+ throw new Error(`Scope ${s} is not supported`)
356
+ }
357
+ }
358
+ function verifyStateAndNonce(state, nonce) {
359
+ assert(state, 'State is required')
360
+ if (/^[A-Za-z0-9/+_\-=.]{30,255}$/.test(state) === false)
361
+ throw new Error('Invalid state')
362
+ assert(nonce, 'Nonce is required')
363
+ if (/^[A-Za-z0-9/+_\-=.]{30,255}$/.test(nonce) === false)
364
+ throw new Error('Invalid nonce')
365
+ }
366
+ function verifyResponseType(responseType) {
367
+ assert(responseType, 'Response type is required')
368
+ if (!Object.values(FapiUtils.SupportedResponseTypes).includes(responseType))
369
+ throw new Error('Invalid response type')
370
+ }
371
+ function verifyAcrValues(acrValue) {
372
+ //Optional parameter
373
+ if (!acrValue) return
374
+ if (!FapiUtils.AllowedAcrValues.includes(acrValue)) {
375
+ throw new Error('Invalid acr values')
376
+ }
377
+ }
378
+ function verifyCodeChallenge(codeChallenge, codeChallengeMethod) {
379
+ assert(codeChallenge, 'Code challenge is required')
380
+ if (codeChallengeMethod !== 'S256')
381
+ throw new Error('Invalid code challenge method')
382
+ assert(codeChallenge, 'Code challenge is required')
383
+ if (!/^[A-Za-z0-9\-_]{43,128}$/.test(codeChallenge))
384
+ throw new Error(
385
+ 'Code challenge must be base64 encoded and at least 43 to 128 characters long',
386
+ )
387
+ }
388
+ function verifyAuthenticationContext(
389
+ authenticationContextType,
390
+ authenticationContextMessage,
391
+ ) {
392
+ //Not enforced if not provided. This parameter seems to fail when provided for some login apps
393
+ if (!authenticationContextType) return
394
+ if (
395
+ !FapiUtils.AllowedAuthenticationContextTypes.includes(
396
+ authenticationContextType,
397
+ )
398
+ )
399
+ throw new Error('Invalid authentication context type')
400
+ assert(
401
+ authenticationContextMessage,
402
+ 'Authentication context message is required',
403
+ )
404
+ if (authenticationContextMessage.length > 100)
405
+ throw new Error(
406
+ 'Authentication context message must be less than 100 characters',
407
+ )
408
+ }
409
+ function verifyCodeVerifier(codeVerifier, codeChallenge) {
410
+ const verifierSha256 = crypto
411
+ .createHash('sha256')
412
+ .update(codeVerifier)
413
+ .digest('base64url')
414
+ if (verifierSha256 !== codeChallenge)
415
+ throw new Error('Code verifier and code challenge do not match')
416
+ }
417
+
418
+ module.exports = FapiService
@@ -0,0 +1,208 @@
1
+ const {
2
+ generateKeyPairSync,
3
+ createPrivateKey,
4
+ createPublicKey,
5
+ randomUUID,
6
+ } = require('crypto')
7
+ const jose = require('jose')
8
+ const { readFileSync } = require('fs')
9
+ const path = require('path')
10
+
11
+ const SupportedScope = {
12
+ OPENID: 'openid',
13
+ UINFIN: 'uinfin',
14
+ USER_IDENTITY: 'user.identity',
15
+ }
16
+ const SupportedClaims = {
17
+ NONCE: 'nonce',
18
+ AUDIENCE: 'aud',
19
+ ISS: 'iss',
20
+ SUB: 'sub',
21
+ EXP: 'exp',
22
+ IAT: 'iat',
23
+ }
24
+ const SupportedGrantTypes = {
25
+ AUTHORIZATION_CODE: 'authorization_code',
26
+ }
27
+
28
+ const SupportedResponseTypes = {
29
+ CODE: 'code',
30
+ }
31
+
32
+ const AllowedAcrValues = [
33
+ 'urn:singpass:authentication:loa:1',
34
+ 'urn:singpass:authentication:loa:2',
35
+ 'urn:singpass:authentication:loa:3',
36
+ ]
37
+ const AllowedAuthenticationContextTypes = ['APP_AUTHENTICATION_DEFAULT']
38
+ const AllowedHttpsRedirectTypes = ['app_claimed_https', 'standard_https']
39
+ const clientAssertionConfig = {
40
+ CLOCK_SKEW: 60, // 1 minute
41
+ MAX_AGE: 120, //2 minutes
42
+ }
43
+ const dpopConfiguration = {
44
+ MAX_AGE: 120,
45
+ ALLOWED_DPOP_HEADER_TYP: ['dpop+jwt'],
46
+ ALLOWED_DPOP_HEADER_ALG: ['ES256'],
47
+ DPOP_CLOCK_SKEW: 60,
48
+ }
49
+
50
+ const idTokenConfiguration = {
51
+ TOKEN_EXPIRY: 1800,
52
+ }
53
+
54
+ const FAPI_PATH = '/v3/fapi'
55
+ const fapiClientConfiguration = {
56
+ //This is only used for the /generate-tokens call. If used, your POST request must also use the same client_id
57
+ client_id: 'mock-fapi-client-id',
58
+
59
+ //If populated, mockpass will fetch from this endpoint. Ensure that enc key and sig key are included
60
+ client_jwks: process.env.FAPI_CLIENT_JWKS_ENDPOINT || null,
61
+ }
62
+
63
+ function getIssuerFromRequest(req) {
64
+ return `${req.protocol}://${req.get('host')}${FAPI_PATH}`
65
+ }
66
+
67
+ function getFapiOpenIdConfiguration(req) {
68
+ const issuer = getIssuerFromRequest(req)
69
+ return {
70
+ issuer,
71
+ pushed_authorization_request_endpoint: `${issuer}/par`,
72
+ authorization_endpoint: `${issuer}/auth`,
73
+ jwks_uri: `${issuer}/.well-known/keys`,
74
+ token_endpoint: `${issuer}/token`,
75
+ response_types_supported: Object.values(SupportedResponseTypes),
76
+ scopes_supported: Object.values(SupportedScope),
77
+ subject_types_supported: ['public'],
78
+ claims_supported: Object.values(SupportedClaims),
79
+ grant_types_supported: Object.values(SupportedGrantTypes),
80
+ token_endpoint_auth_methods_supported: ['private_key_jwt'],
81
+ token_endpoint_auth_signing_alg_values_supported: ['ES256'],
82
+ id_token_signing_alg_values_supported: ['ES256'],
83
+ id_token_encryption_alg_values_supported: [
84
+ 'ECDH-ES+A256KW',
85
+ 'ECDH-ES+A192KW',
86
+ 'ECDH-ES+A128KW',
87
+ ],
88
+ id_token_encryption_enc_values_supported: ['A256CBC-HS512'],
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Helper function to generate a DPoP, client assertion token, and return the ephemeral private key.
94
+ */
95
+ async function generateDpopAndClientAssertionToken(req) {
96
+ const { publicKey, privateKey } = generateKeys(req.body.ephemeralPrivateKey)
97
+ const dpopToken = await generateDpopToken(req, publicKey, privateKey)
98
+ const clientAssertionToken = await generateClientAssertionToken(req)
99
+ return {
100
+ ephemeralPrivateKey: privateKey,
101
+ dpopToken,
102
+ clientAssertionToken,
103
+ }
104
+ }
105
+ function generateKeys(ephemeralPrivateKey) {
106
+ let publicKey, privateKey
107
+ if (ephemeralPrivateKey) {
108
+ //For later part of the FAPI flow, as the ephemeral private key needs to be reused.
109
+ privateKey = ephemeralPrivateKey
110
+ if (typeof privateKey === 'string') {
111
+ privateKey = privateKey.replace(/\\n/g, '\n')
112
+ }
113
+ const keyObject = createPrivateKey({
114
+ key: privateKey,
115
+ format: 'pem',
116
+ })
117
+ publicKey = createPublicKey(keyObject).export({ format: 'jwk' })
118
+ } else {
119
+ //Generate a new key if no private key is provided.
120
+ const keyPair = generateKeyPairSync('ec', {
121
+ namedCurve: 'P-256',
122
+ publicKeyEncoding: { format: 'jwk' },
123
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
124
+ })
125
+ publicKey = keyPair.publicKey
126
+ privateKey = keyPair.privateKey
127
+ }
128
+ return { publicKey, privateKey }
129
+ }
130
+ async function generateDpopToken(req, publicKey, privateKey) {
131
+ const dpop = {
132
+ max_age: dpopConfiguration.MAX_AGE,
133
+ header: {
134
+ alg: 'ES256',
135
+ typ: 'dpop+jwt',
136
+ jwk: {
137
+ kty: publicKey.kty,
138
+ crv: publicKey.crv,
139
+ x: publicKey.x,
140
+ y: publicKey.y,
141
+ },
142
+ },
143
+ body: {
144
+ htu: req.body.endpoint,
145
+ htm: 'POST',
146
+ jti: randomUUID(),
147
+ iat: Math.floor(Date.now() / 1000),
148
+ exp: Math.floor(Date.now() / 1000) + dpopConfiguration.MAX_AGE,
149
+ nonce: randomUUID(),
150
+ },
151
+ }
152
+ const dpopKey = createPrivateKey({
153
+ key: privateKey,
154
+ format: 'pem',
155
+ type: 'pkcs8',
156
+ })
157
+ return await new jose.SignJWT(dpop.body)
158
+ .setProtectedHeader(dpop.header)
159
+ .sign(dpopKey)
160
+ }
161
+ async function generateClientAssertionToken(req) {
162
+ const clientAssertionPrivateKey = JSON.parse(
163
+ readFileSync(
164
+ path.resolve(__dirname, '../../../static/certs/fapi-private.json'),
165
+ ),
166
+ )['keys'].find((key) => key.use === 'sig')
167
+ const clientAssertionPublicKey = JSON.parse(
168
+ readFileSync(
169
+ path.resolve(__dirname, '../../../static/certs/fapi-public.json'),
170
+ ),
171
+ )['keys'].find((key) => key.use === 'sig')
172
+ const clientAssertion = {
173
+ headers: {
174
+ alg: 'ES256',
175
+ kid: clientAssertionPublicKey.kid,
176
+ typ: 'JWT',
177
+ },
178
+ payload: {
179
+ iss: fapiClientConfiguration.client_id,
180
+ sub: fapiClientConfiguration.client_id,
181
+ aud: getFapiOpenIdConfiguration(req).issuer,
182
+ jti: randomUUID(),
183
+ iat: Math.floor(Date.now() / 1000),
184
+ exp: Math.floor(Date.now() / 1000) + 120,
185
+ },
186
+ }
187
+
188
+ const clientKey = await jose.importJWK(clientAssertionPrivateKey, 'ES256')
189
+ return await new jose.SignJWT(clientAssertion.payload)
190
+ .setProtectedHeader(clientAssertion.headers)
191
+ .sign(clientKey)
192
+ }
193
+
194
+ module.exports = {
195
+ AllowedAcrValues,
196
+ AllowedAuthenticationContextTypes,
197
+ AllowedHttpsRedirectTypes,
198
+ clientAssertionConfig,
199
+ dpopConfiguration,
200
+ FAPI_PATH,
201
+ fapiClientConfiguration,
202
+ generateDpopAndClientAssertionToken,
203
+ getFapiOpenIdConfiguration,
204
+ idTokenConfiguration,
205
+ SupportedGrantTypes,
206
+ SupportedResponseTypes,
207
+ SupportedScope,
208
+ }
@@ -3,4 +3,5 @@ module.exports = {
3
3
  configMyInfo: require('./myinfo'),
4
4
  configSGID: require('./sgid'),
5
5
  configSignV3: require('./signv3'),
6
+ configFapi: require('./fapi/fapi.controller'),
6
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengovsg/mockpass",
3
- "version": "4.5.7",
3
+ "version": "4.6.0",
4
4
  "description": "A mock SingPass/CorpPass server for dev purposes",
5
5
  "main": "app.js",
6
6
  "bin": {
@@ -41,6 +41,7 @@
41
41
  "dotenv": "^17.2.0",
42
42
  "expiry-map": "^2.0.0",
43
43
  "express": "^5.1.0",
44
+ "express-session": "^1.19.0",
44
45
  "jose": "^5.2.3",
45
46
  "jsonwebtoken": "^9.0.0",
46
47
  "lodash": "^4.17.11",
@@ -0,0 +1,23 @@
1
+ {
2
+ "keys": [
3
+ {
4
+ "kty": "EC",
5
+ "x": "tkRXMLpC7djlH7cqntg8-fuekxG9YTvJx8IsRKApcAg",
6
+ "y": "elyS0xZn3ymk65tVPYO3pZplEaOEZtj_RegJ3_cwq7A",
7
+ "crv": "P-256",
8
+ "d": "uRwK14a2icjic0DSFsOG2PgKgqfZobaqhjgGS0wbkho",
9
+ "use": "sig",
10
+ "alg": "ES256"
11
+ },
12
+ {
13
+ "kty": "EC",
14
+ "x": "fvSpp2PLnde3dtY8VpY881WUxijtSqmhu4daeavEuKQ",
15
+ "y": "LzH6bK3nxZFPh8tPLjUN0EMYnIgQjkyUiafh4Eafmw8",
16
+ "crv": "P-256",
17
+ "d": "wqtEFMSkoWeYqfZ-aqbSn5CE2KmgqWZgxrowWQaHCXA",
18
+ "use": "enc",
19
+ "alg": "ECDH-ES+A256KW",
20
+ "kid": "fapi-enc-key"
21
+ }
22
+ ]
23
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "keys": [
3
+ {
4
+ "kty": "EC",
5
+ "crv": "P-256",
6
+ "x": "tkRXMLpC7djlH7cqntg8-fuekxG9YTvJx8IsRKApcAg",
7
+ "y": "elyS0xZn3ymk65tVPYO3pZplEaOEZtj_RegJ3_cwq7A",
8
+ "kid": "fapi-key-01",
9
+ "use": "sig",
10
+ "alg": "ES256"
11
+ },
12
+ {
13
+ "kty": "EC",
14
+ "x": "fvSpp2PLnde3dtY8VpY881WUxijtSqmhu4daeavEuKQ",
15
+ "y": "LzH6bK3nxZFPh8tPLjUN0EMYnIgQjkyUiafh4Eafmw8",
16
+ "crv": "P-256",
17
+ "use": "enc",
18
+ "alg": "ECDH-ES+A256KW",
19
+ "kid": "fapi-enc-key"
20
+ }
21
+ ]
22
+ }