@opengovsg/mockpass 4.5.8 → 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 +45 -5
- package/app.js +2 -0
- package/lib/auth-code.js +8 -1
- package/lib/express/fapi/fapi.controller.js +75 -0
- package/lib/express/fapi/fapi.service.js +418 -0
- package/lib/express/fapi/utils.js +208 -0
- package/lib/express/index.js +1 -0
- package/package.json +2 -1
- package/static/certs/fapi-private.json +23 -0
- package/static/certs/fapi-public.json +22 -0
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
|
+
}
|
package/lib/express/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengovsg/mockpass",
|
|
3
|
-
"version": "4.
|
|
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
|
+
}
|