@opengovsg/mockpass 4.6.11 → 4.7.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 +65 -15
- package/lib/auth-code.js +23 -1
- package/lib/express/fapi/fapi.controller.js +10 -0
- package/lib/express/fapi/fapi.service.js +187 -74
- package/lib/express/fapi/userinfo-error.js +39 -0
- package/lib/express/fapi/utils.js +23 -9
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -20,37 +20,84 @@ A mock Singpass/Corppass/Myinfo v3/sgID v2/Sign v3 server for dev purposes
|
|
|
20
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
21
|
|
|
22
22
|
Configure your endpoint to point to the following endpoints:
|
|
23
|
+
|
|
23
24
|
- http://localhost:5156/singpass/v3/fapi/.well-known/openid-configuration
|
|
24
25
|
- http://localhost:5156/singpass/v3/fapi/.well-known/keys
|
|
25
26
|
- http://localhost:5156/singpass/v3/fapi/par
|
|
26
27
|
- http://localhost:5156/singpass/v3/fapi/auth
|
|
27
28
|
- http://localhost:5156/singpass/v3/fapi/token
|
|
29
|
+
- http://localhost:5156/singpass/v3/fapi/userinfo
|
|
28
30
|
|
|
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-rp-private.json` and `fapi-rp-public.json` files. If configured, Mockpass will attempt to fetch the JWKS from the specified endpoint.
|
|
31
|
+
In the `/fapi/utils.js file`, you can configure your client JWKS endpoint in the `fapiClientConfiguration`. By default, it is set to `null` and Mockpass will read the default keys that are stored in the `fapi-rp-private.json` and `fapi-rp-public.json` files. If configured, Mockpass will attempt to fetch the JWKS from the specified endpoint.
|
|
31
32
|
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
|
|
|
33
34
|
Limitations:
|
|
35
|
+
|
|
34
36
|
- `client_id` and `redirect_uri` can be set to anything.
|
|
35
37
|
- Mockpass will not check if ephemeral keys, state, and nonce are reused.
|
|
36
|
-
-
|
|
37
|
-
- Only `openid` `user.identity` `uinfin` is supported for the `scope` parameter.
|
|
38
|
+
- `scope` accepts `openid`, `user.identity`, and any basic MyInfo attribute (`uinfin`, `name`, `dob`, `regadd`, ...)
|
|
38
39
|
- Only `urn:singpass:authentication:loa:1` is supported for the `acr_values` parameter.
|
|
39
40
|
|
|
41
|
+
### Userinfo (MyInfo apps only)
|
|
42
|
+
|
|
43
|
+
`GET /singpass/v3/fapi/userinfo` returns the person data for the scopes requested at `/par`, as a signed-then-encrypted JWT using the same keys as the `id_token`. Refer to: https://docs.developer.singpass.gov.sg/docs/technical-specifications/integration-guide/5.-requesting-for-userinfo
|
|
44
|
+
|
|
45
|
+
The request takes no query parameters and requires two headers:
|
|
46
|
+
|
|
47
|
+
| Header | Value |
|
|
48
|
+
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
49
|
+
| `Authorization` | `DPoP <access_token>` — the access token from `/token`, valid for 30 minutes and reusable within that window. |
|
|
50
|
+
| `DPoP` | A proof JWT with `htm: GET`, `htu` set to the userinfo endpoint, and an `ath` claim holding the base64url SHA-256 of the access token. It must be signed with the same ephemeral key used at `/token`. |
|
|
51
|
+
|
|
52
|
+
The decrypted payload looks like:
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"person_info": {
|
|
57
|
+
"uinfin": {
|
|
58
|
+
"lastupdated": "2020-04-16",
|
|
59
|
+
"source": "1",
|
|
60
|
+
"classification": "C",
|
|
61
|
+
"value": "S9812379B"
|
|
62
|
+
},
|
|
63
|
+
"name": {
|
|
64
|
+
"lastupdated": "2020-04-16",
|
|
65
|
+
"source": "1",
|
|
66
|
+
"classification": "C",
|
|
67
|
+
"value": "LIM YONG XIANG"
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
"iss": "http://localhost:5156/singpass/v3/fapi",
|
|
71
|
+
"sub": "952b0342-0649-a6fe-245b-87cfcc3d38da",
|
|
72
|
+
"aud": "mock-fapi-client-id",
|
|
73
|
+
"iat": 1746678089
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Notes:
|
|
78
|
+
|
|
79
|
+
- Only login profiles marked `[MyInfo]` on the login page carry real person data. For any other profile — including one entered through the custom profile form — `uinfin` is filled in from the NRIC you supplied and every other attribute comes back as `{ "lastupdated": "", "source": "", "classification": "", "unavailable": true }`.
|
|
80
|
+
- `person_info` is always present, and is `{}` when only `openid` was requested. Apps that do not use MyInfo can ignore this endpoint entirely.
|
|
81
|
+
- Errors follow the OAuth shape: `invalid_token` and `invalid_dpop_proof` at 401 (with a `WWW-Authenticate` header), `invalid_request` at 400.
|
|
82
|
+
|
|
40
83
|
### Helper functions
|
|
41
84
|
|
|
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.
|
|
85
|
+
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.
|
|
86
|
+
|
|
43
87
|
- POST: http://localhost:5156/singpass/v3/fapi/tests/generate-tokens
|
|
44
88
|
#### Request Body
|
|
45
89
|
|
|
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. |
|
|
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` |
|
|
90
|
+
| Body | Description | Example |
|
|
91
|
+
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
92
|
+
| 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-----` |
|
|
93
|
+
| 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`, `http://localhost:5156/singpass/v3/fapi/userinfo` |
|
|
94
|
+
| method | Optional, defaults to `POST`. Populates the htm parameter in the DPoP token. Set this to `GET` when calling the userinfo endpoint. | `GET` |
|
|
95
|
+
| access_token | Optional. When provided, an `ath` claim holding the base64url SHA-256 of this token is added to the DPoP proof. Required when calling the userinfo endpoint. | The `access_token` returned by `/token` |
|
|
50
96
|
|
|
51
97
|
#### Response Body
|
|
98
|
+
|
|
52
99
|
| Body | Description | Type |
|
|
53
|
-
|
|
100
|
+
| -------------------- | ------------------------------------------------------------------------------------------------------------------- | ---------- |
|
|
54
101
|
| dpopToken | The dpop token that is used for the API call. | jwt |
|
|
55
102
|
| clientAssertionToken | The client assertion token that is used for the API call. | jwt |
|
|
56
103
|
| 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 |
|
|
@@ -74,7 +121,7 @@ Configure your application (or MockPass) with keys:
|
|
|
74
121
|
MockPass accepts any value for `client_id` and `redirect_uri`.
|
|
75
122
|
|
|
76
123
|
| Configuration item | Explanation |
|
|
77
|
-
|
|
124
|
+
| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
78
125
|
| 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. |
|
|
79
126
|
| 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. |
|
|
80
127
|
| 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 |
|
|
@@ -98,7 +145,7 @@ Configure your application (or MockPass) with keys:
|
|
|
98
145
|
MockPass accepts any value for `client_id` and `redirect_uri`.
|
|
99
146
|
|
|
100
147
|
| Configuration item | Explanation |
|
|
101
|
-
|
|
148
|
+
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
102
149
|
| 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. |
|
|
103
150
|
| 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. |
|
|
104
151
|
|
|
@@ -130,7 +177,7 @@ succeed, using other NRICs will result in an error. See the list of personas in
|
|
|
130
177
|
[static/myinfo/v3.json](static/myinfo/v3.json).
|
|
131
178
|
|
|
132
179
|
| Configuration item | Explanation |
|
|
133
|
-
|
|
180
|
+
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
134
181
|
| 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.) |
|
|
135
182
|
| 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. |
|
|
136
183
|
| 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`. |
|
|
@@ -174,7 +221,7 @@ identify them.
|
|
|
174
221
|
The `pocdex.number_of_employments` scope is not supported.
|
|
175
222
|
|
|
176
223
|
| Configuration item | Explanation |
|
|
177
|
-
|
|
224
|
+
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
178
225
|
| 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.) |
|
|
179
226
|
| 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. |
|
|
180
227
|
|
|
@@ -205,9 +252,11 @@ Alternatively, provide your application with the certificate and private key
|
|
|
205
252
|
from `static/certs/(server.crt|key.pem)`.
|
|
206
253
|
|
|
207
254
|
### Sign v3
|
|
255
|
+
|
|
208
256
|
Check out Sign V3 documentation [here](https://docs.sign.singpass.gov.sg/for-relying-parties/api-documentation/document-signing-v3).
|
|
209
257
|
|
|
210
258
|
Sign v3 URLs
|
|
259
|
+
|
|
211
260
|
- http://localhost:5156/sign-v3/sign-requests - Create sign request
|
|
212
261
|
- http://localhost:5156/sign-v3/sign-requests/:request_id/signed-doc - Get signed document
|
|
213
262
|
- http://localhost:5156/sign-v3/jwks - JWKS URL
|
|
@@ -215,6 +264,7 @@ Sign v3 URLs
|
|
|
215
264
|
- http://localhost:5156/sign-v3/sign - The stubbed signing portal path
|
|
216
265
|
|
|
217
266
|
Configure MockPass with your application client details:
|
|
267
|
+
|
|
218
268
|
- Client ID: SIGNV3_CLIENT_ID, default mockpass-sign-v3-client
|
|
219
269
|
- JWKS URL: SIGNV3_CLIENT_JWKS_URL, default http://localhost:4000/jwks
|
|
220
270
|
- Redirect URL: SIGNV3_CLIENT_REDIRECT_URL, default http://localhost:4000/redirect
|
|
@@ -226,7 +276,7 @@ Configure MockPass with your application client details:
|
|
|
226
276
|
Common configuration:
|
|
227
277
|
|
|
228
278
|
| Configuration item | Explanation |
|
|
229
|
-
|
|
279
|
+
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
230
280
|
| 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. |
|
|
231
281
|
| 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`. |
|
|
232
282
|
|
package/lib/auth-code.js
CHANGED
|
@@ -4,6 +4,11 @@ const crypto = require('crypto')
|
|
|
4
4
|
const AUTH_CODE_TIMEOUT = 5 * 60 * 1000
|
|
5
5
|
const profileAndNonceStore = new ExpiryMap(AUTH_CODE_TIMEOUT)
|
|
6
6
|
|
|
7
|
+
const FAPI_ACCESS_TOKEN_EXPIRY_SECONDS = 30 * 60
|
|
8
|
+
const fapiAccessTokenStore = new ExpiryMap(
|
|
9
|
+
FAPI_ACCESS_TOKEN_EXPIRY_SECONDS * 1000,
|
|
10
|
+
)
|
|
11
|
+
|
|
7
12
|
const generateAuthCode = (
|
|
8
13
|
{ profile, scopes, nonce, clientId = '' },
|
|
9
14
|
{ isStateless = false },
|
|
@@ -31,4 +36,21 @@ const lookUpByAuthCode = (authCode, { isStateless = false }) => {
|
|
|
31
36
|
: profileAndNonceStore.get(authCode)
|
|
32
37
|
}
|
|
33
38
|
|
|
34
|
-
|
|
39
|
+
const generateAccessTokenForFapi = ({ profile, authRequest, dpopJkt }) => {
|
|
40
|
+
const accessToken = crypto.randomBytes(64).toString('base64url')
|
|
41
|
+
|
|
42
|
+
fapiAccessTokenStore.set(accessToken, { profile, authRequest, dpopJkt })
|
|
43
|
+
return accessToken
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const lookUpByAccessToken = (accessToken) =>
|
|
47
|
+
fapiAccessTokenStore.get(accessToken)
|
|
48
|
+
|
|
49
|
+
module.exports = {
|
|
50
|
+
FAPI_ACCESS_TOKEN_EXPIRY_SECONDS,
|
|
51
|
+
generateAccessTokenForFapi,
|
|
52
|
+
generateAuthCode,
|
|
53
|
+
generateAuthCodeForFapi,
|
|
54
|
+
lookUpByAccessToken,
|
|
55
|
+
lookUpByAuthCode,
|
|
56
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const FapiService = require('./fapi.service.js')
|
|
2
2
|
const FapiUtils = require('./utils.js')
|
|
3
|
+
const { sendUserInfoError } = require('./userinfo-error.js')
|
|
3
4
|
const express = require('express')
|
|
4
5
|
const assertions = require('../../assertions')
|
|
5
6
|
const fs = require('fs')
|
|
@@ -65,6 +66,15 @@ function config(app) {
|
|
|
65
66
|
}
|
|
66
67
|
})
|
|
67
68
|
|
|
69
|
+
app.get(`${fapiRoutesBasePath}/userinfo`, async (req, res) => {
|
|
70
|
+
try {
|
|
71
|
+
const userInfo = await service.handleUserInfoRequest(req)
|
|
72
|
+
return res.status(200).type('application/jwt').send(userInfo)
|
|
73
|
+
} catch (e) {
|
|
74
|
+
return sendUserInfoError(res, e)
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
|
|
68
78
|
//Helper function to generate DPoP proof JWT and client assertion token for testing. In real implementation, these tokens should be generated by the client.
|
|
69
79
|
app.post(`${fapiRoutesBasePath}/tests/generate-tokens`, async (req, res) => {
|
|
70
80
|
try {
|
|
@@ -4,12 +4,37 @@ const FapiUtils = require('./utils.js')
|
|
|
4
4
|
const { readFileSync } = require('fs')
|
|
5
5
|
const path = require('path')
|
|
6
6
|
const jose = require('jose')
|
|
7
|
-
const {
|
|
7
|
+
const {
|
|
8
|
+
FAPI_ACCESS_TOKEN_EXPIRY_SECONDS,
|
|
9
|
+
generateAccessTokenForFapi,
|
|
10
|
+
generateAuthCodeForFapi,
|
|
11
|
+
lookUpByAccessToken,
|
|
12
|
+
lookUpByAuthCode,
|
|
13
|
+
} = require('../../auth-code')
|
|
14
|
+
const {
|
|
15
|
+
invalidDpopProof,
|
|
16
|
+
invalidRequest,
|
|
17
|
+
invalidToken,
|
|
18
|
+
} = require('./userinfo-error')
|
|
8
19
|
const fs = require('fs')
|
|
9
20
|
const { buildAssertURL, idGenerator } = require('../oidc/utils')
|
|
10
21
|
const { render } = require('mustache')
|
|
11
22
|
const ExpiryMap = require('expiry-map')
|
|
12
23
|
|
|
24
|
+
const makeClaim = (value) => ({
|
|
25
|
+
lastupdated: '2023-03-23',
|
|
26
|
+
source: '1',
|
|
27
|
+
classification: 'C',
|
|
28
|
+
value,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const makeUnavailableClaim = () => ({
|
|
32
|
+
lastupdated: '',
|
|
33
|
+
source: '',
|
|
34
|
+
classification: '',
|
|
35
|
+
unavailable: true,
|
|
36
|
+
})
|
|
37
|
+
|
|
13
38
|
class FapiService {
|
|
14
39
|
constructor() {
|
|
15
40
|
this.map = new ExpiryMap(5 * 60 * 1000) // PAR request expires in 5 minutes
|
|
@@ -64,13 +89,18 @@ class FapiService {
|
|
|
64
89
|
isStateless: false,
|
|
65
90
|
})
|
|
66
91
|
if (!authCodeSession) throw new Error('No auth request found in session')
|
|
67
|
-
const { authRequest } = authCodeSession
|
|
92
|
+
const { authRequest, profile } = authCodeSession
|
|
93
|
+
if (!profile) throw new Error('No profile found in session')
|
|
68
94
|
verifyTokenRequestBody(req, authRequest)
|
|
69
95
|
const tokenEndpoint =
|
|
70
96
|
FapiUtils.getFapiOpenIdConfiguration(req).token_endpoint
|
|
71
97
|
await verifyClientAssertion(req)
|
|
72
|
-
await verifyDpop(
|
|
73
|
-
|
|
98
|
+
const dpopJkt = await verifyDpop(
|
|
99
|
+
req.headers['dpop'],
|
|
100
|
+
authRequest.dpopJkt,
|
|
101
|
+
tokenEndpoint,
|
|
102
|
+
)
|
|
103
|
+
return this.generateTokenResponse(req, { profile, authRequest, dpopJkt })
|
|
74
104
|
}
|
|
75
105
|
|
|
76
106
|
handleCustomProfileAuthorizationRequest(req) {
|
|
@@ -120,11 +150,8 @@ class FapiService {
|
|
|
120
150
|
})
|
|
121
151
|
}
|
|
122
152
|
|
|
123
|
-
async
|
|
124
|
-
//Generate tokens
|
|
153
|
+
async generateTokenResponse(req, { profile, authRequest, dpopJkt }) {
|
|
125
154
|
const expires_in = FapiUtils.idTokenConfiguration.TOKEN_EXPIRY //30 minutes
|
|
126
|
-
const { profile } = lookUpByAuthCode(req.body.code, { isStateless: false })
|
|
127
|
-
if (!profile) throw new Error('No profile found in session')
|
|
128
155
|
const id_token = {
|
|
129
156
|
sub: profile.uuid,
|
|
130
157
|
sub_attributes: {
|
|
@@ -140,75 +167,154 @@ class FapiService {
|
|
|
140
167
|
amr: ['pwd'],
|
|
141
168
|
iss: FapiUtils.getFapiOpenIdConfiguration(req).issuer,
|
|
142
169
|
exp: Math.floor(Date.now() / 1000) + expires_in,
|
|
143
|
-
iat: Date.now() / 1000,
|
|
170
|
+
iat: Math.floor(Date.now() / 1000),
|
|
144
171
|
nonce: authRequest.nonce,
|
|
145
172
|
}
|
|
146
173
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
),
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
)
|
|
157
|
-
if (!fapiSigningPrivateKey)
|
|
158
|
-
throw new Error(`No signing key found for kid "${signingKid}"`)
|
|
159
|
-
|
|
160
|
-
const signingKey = await jose.importJWK(fapiSigningPrivateKey, 'ES256')
|
|
161
|
-
const signedProtectedHeader = {
|
|
162
|
-
alg: 'ES256',
|
|
163
|
-
typ: 'JWT',
|
|
164
|
-
kid: signingKid,
|
|
174
|
+
return {
|
|
175
|
+
access_token: generateAccessTokenForFapi({
|
|
176
|
+
profile,
|
|
177
|
+
authRequest,
|
|
178
|
+
dpopJkt,
|
|
179
|
+
}),
|
|
180
|
+
id_token: await signAndEncrypt(id_token),
|
|
181
|
+
token_type: 'DPoP',
|
|
182
|
+
expires_in: FAPI_ACCESS_TOKEN_EXPIRY_SECONDS,
|
|
165
183
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
),
|
|
185
|
-
)['keys'].find((key) => key.use === 'enc')
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async handleUserInfoRequest(req) {
|
|
187
|
+
const accessToken = extractDpopAccessToken(req)
|
|
188
|
+
const session = lookUpByAccessToken(accessToken)
|
|
189
|
+
if (!session) throw invalidToken('Access token is invalid or has expired')
|
|
190
|
+
|
|
191
|
+
const { profile, authRequest, dpopJkt } = session
|
|
192
|
+
const { issuer, userinfo_endpoint } =
|
|
193
|
+
FapiUtils.getFapiOpenIdConfiguration(req)
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
await verifyDpop(req.headers['dpop'], dpopJkt, userinfo_endpoint, {
|
|
197
|
+
expectedMethod: 'GET',
|
|
198
|
+
accessToken,
|
|
199
|
+
})
|
|
200
|
+
} catch (error) {
|
|
201
|
+
throw invalidDpopProof(error.message)
|
|
186
202
|
}
|
|
187
|
-
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
cty: 'JWT',
|
|
203
|
+
|
|
204
|
+
const userInfo = {
|
|
205
|
+
person_info: buildPersonInfo(profile, authRequest.scope),
|
|
206
|
+
iss: issuer,
|
|
207
|
+
sub: profile.uuid,
|
|
208
|
+
aud: authRequest.client_id,
|
|
209
|
+
iat: Math.floor(Date.now() / 1000),
|
|
195
210
|
}
|
|
196
|
-
const encryptedIdToken = await new jose.CompactEncrypt(
|
|
197
|
-
new TextEncoder().encode(signedIdToken),
|
|
198
|
-
)
|
|
199
|
-
.setProtectedHeader(encrpytionProtectedHeader)
|
|
200
|
-
.encrypt(encKey)
|
|
201
211
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
212
|
+
// An unreachable or keyless client JWKS is the caller's problem; a missing
|
|
213
|
+
// ASP signing key is ours, and falls through to a 500.
|
|
214
|
+
const signedUserInfo = await signWithAspSigningKey(userInfo)
|
|
215
|
+
try {
|
|
216
|
+
return await encryptForClient(signedUserInfo)
|
|
217
|
+
} catch (error) {
|
|
218
|
+
throw invalidRequest(error.message)
|
|
206
219
|
}
|
|
207
220
|
}
|
|
208
221
|
}
|
|
209
222
|
|
|
223
|
+
async function signAndEncrypt(payload) {
|
|
224
|
+
return encryptForClient(await signWithAspSigningKey(payload))
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function signWithAspSigningKey(payload) {
|
|
228
|
+
const signingKid = FapiUtils.FAPI_ASP_SIGNING_KID
|
|
229
|
+
const fapiPrivateJwks = JSON.parse(
|
|
230
|
+
fs.readFileSync(
|
|
231
|
+
path.resolve(__dirname, FapiUtils.FAPI_ASP_PRIVATE_JWKS_PATH),
|
|
232
|
+
),
|
|
233
|
+
)
|
|
234
|
+
const fapiSigningPrivateKey = fapiPrivateJwks.keys.find(
|
|
235
|
+
(item) => item.use === 'sig' && item.kid === signingKid,
|
|
236
|
+
)
|
|
237
|
+
if (!fapiSigningPrivateKey)
|
|
238
|
+
throw new Error(`No signing key found for kid "${signingKid}"`)
|
|
239
|
+
|
|
240
|
+
const signingKey = await jose.importJWK(fapiSigningPrivateKey, 'ES256')
|
|
241
|
+
const signedProtectedHeader = {
|
|
242
|
+
alg: 'ES256',
|
|
243
|
+
typ: 'JWT',
|
|
244
|
+
kid: signingKid,
|
|
245
|
+
}
|
|
246
|
+
return new jose.CompactSign(new TextEncoder().encode(JSON.stringify(payload)))
|
|
247
|
+
.setProtectedHeader(signedProtectedHeader)
|
|
248
|
+
.sign(signingKey)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function encryptForClient(signedJwt) {
|
|
252
|
+
let rpEncPublicKey
|
|
253
|
+
if (FapiUtils.fapiClientConfiguration.client_jwks) {
|
|
254
|
+
const jwks = await fetch(
|
|
255
|
+
FapiUtils.fapiClientConfiguration.client_jwks,
|
|
256
|
+
).then((res) => res.json())
|
|
257
|
+
if (!jwks.keys) throw new Error('Unable to fetch client jwks')
|
|
258
|
+
rpEncPublicKey = jwks.keys.find((key) => key.use === 'enc')
|
|
259
|
+
} else {
|
|
260
|
+
rpEncPublicKey = JSON.parse(
|
|
261
|
+
readFileSync(path.resolve(__dirname, FapiUtils.FAPI_RP_PUBLIC_JWKS_PATH)),
|
|
262
|
+
)['keys'].find((key) => key.use === 'enc')
|
|
263
|
+
}
|
|
264
|
+
if (!rpEncPublicKey) throw new Error('No suitable encryption key found')
|
|
265
|
+
|
|
266
|
+
const encKey = await jose.importJWK(rpEncPublicKey)
|
|
267
|
+
const encryptionProtectedHeader = {
|
|
268
|
+
alg: rpEncPublicKey.alg,
|
|
269
|
+
typ: 'JWT',
|
|
270
|
+
kid: rpEncPublicKey.kid,
|
|
271
|
+
enc: 'A256GCM',
|
|
272
|
+
cty: 'JWT',
|
|
273
|
+
}
|
|
274
|
+
return new jose.CompactEncrypt(new TextEncoder().encode(signedJwt))
|
|
275
|
+
.setProtectedHeader(encryptionProtectedHeader)
|
|
276
|
+
.encrypt(encKey)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function extractDpopAccessToken(req) {
|
|
280
|
+
const authorization = req.get('authorization')
|
|
281
|
+
if (!authorization) throw invalidToken('Missing Authorization header')
|
|
282
|
+
|
|
283
|
+
const [scheme, token] = authorization.trim().split(' ')
|
|
284
|
+
if (scheme !== 'DPoP' || !token)
|
|
285
|
+
throw invalidToken('Authorization header must use the DPoP scheme')
|
|
286
|
+
|
|
287
|
+
return token
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Only MyInfo persona profiles carry claims. The rest can answer for the uinfin
|
|
291
|
+
// they were created with, and nothing else.
|
|
292
|
+
function lookUpPersonAttribute(profile, attribute) {
|
|
293
|
+
const claim = profile.claims && profile.claims[attribute]
|
|
294
|
+
if (claim) return claim
|
|
295
|
+
if (attribute === 'uinfin') return makeClaim(profile.nric)
|
|
296
|
+
return makeUnavailableClaim()
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function buildPersonInfo(profile, scope) {
|
|
300
|
+
const requestedAttributes = scope
|
|
301
|
+
.split(' ')
|
|
302
|
+
.filter((requested) => FapiUtils.MyInfoPersonScopes.includes(requested))
|
|
303
|
+
|
|
304
|
+
const personInfo = {}
|
|
305
|
+
for (const attribute of requestedAttributes) {
|
|
306
|
+
personInfo[attribute] = lookUpPersonAttribute(profile, attribute)
|
|
307
|
+
}
|
|
308
|
+
return personInfo
|
|
309
|
+
}
|
|
310
|
+
|
|
210
311
|
//Verification functions
|
|
211
|
-
async function verifyDpop(
|
|
312
|
+
async function verifyDpop(
|
|
313
|
+
dpop,
|
|
314
|
+
dpop_jkt,
|
|
315
|
+
expectedEndpoint,
|
|
316
|
+
{ expectedMethod = 'POST', accessToken = null } = {},
|
|
317
|
+
) {
|
|
212
318
|
if (!dpop && !dpop_jkt) throw new Error('Dpop or Dpop jkt is required')
|
|
213
319
|
if (dpop) {
|
|
214
320
|
const dpopToken = dpop.split(' ')[0]
|
|
@@ -216,27 +322,34 @@ async function verifyDpop(dpop, dpop_jkt, expectedEndpoint) {
|
|
|
216
322
|
const header = JSON.parse(Buffer.from(headerB64, 'base64').toString())
|
|
217
323
|
const jwk = header.jwk
|
|
218
324
|
if (!jwk) throw new Error('DPoP header missing jwk')
|
|
325
|
+
|
|
326
|
+
const requiredClaims = ['jti', 'iat', 'htu', 'htm']
|
|
327
|
+
if (accessToken) requiredClaims.push('ath')
|
|
328
|
+
|
|
329
|
+
let payload
|
|
219
330
|
try {
|
|
220
331
|
const key = await jose.importJWK(jwk, 'ES256')
|
|
221
|
-
await jose.jwtVerify(dpopToken, key, {
|
|
332
|
+
const verified = await jose.jwtVerify(dpopToken, key, {
|
|
222
333
|
clockTolerance: FapiUtils.dpopConfiguration.DPOP_CLOCK_SKEW,
|
|
223
|
-
requiredClaims
|
|
334
|
+
requiredClaims,
|
|
224
335
|
})
|
|
336
|
+
payload = verified.payload
|
|
225
337
|
} catch (error) {
|
|
226
338
|
throw new Error(`Invalid DPoP token signature, ${error.message}`)
|
|
227
339
|
}
|
|
228
|
-
|
|
229
|
-
const
|
|
230
|
-
const payload = JSON.parse(Buffer.from(payloadB64, 'base64').toString())
|
|
231
|
-
const { htu, htm } = payload
|
|
340
|
+
|
|
341
|
+
const { htu, htm, ath } = payload
|
|
232
342
|
if (htu.toLowerCase() !== expectedEndpoint.toLowerCase()) {
|
|
233
343
|
throw new Error('Invalid DPoP htu')
|
|
234
344
|
}
|
|
235
345
|
if (typeof htm !== 'string' || !htm) {
|
|
236
346
|
throw new Error('DPoP htm must be a non-empty string')
|
|
237
347
|
}
|
|
238
|
-
if (htm.toUpperCase() !==
|
|
239
|
-
throw new Error(
|
|
348
|
+
if (htm.toUpperCase() !== expectedMethod) {
|
|
349
|
+
throw new Error(`DPoP htm must be ${expectedMethod} for this endpoint`)
|
|
350
|
+
}
|
|
351
|
+
if (accessToken && ath !== FapiUtils.hashAccessToken(accessToken)) {
|
|
352
|
+
throw new Error('Invalid DPoP ath')
|
|
240
353
|
}
|
|
241
354
|
const jwkThumbprint = crypto
|
|
242
355
|
.createHash('sha256')
|
|
@@ -392,7 +505,7 @@ function verifyAllowedScopes(scope) {
|
|
|
392
505
|
assert(scope, 'Scope is required')
|
|
393
506
|
const scopes = scope.split(' ')
|
|
394
507
|
for (const s of scopes) {
|
|
395
|
-
if (!
|
|
508
|
+
if (!FapiUtils.SupportedScopes.includes(s))
|
|
396
509
|
throw new Error(`Scope ${s} is not supported`)
|
|
397
510
|
}
|
|
398
511
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Unlike the other FAPI endpoints, /userinfo answers with the OAuth error shape.
|
|
2
|
+
class UserInfoError extends Error {
|
|
3
|
+
constructor(error, description, status) {
|
|
4
|
+
super(description)
|
|
5
|
+
this.error = error
|
|
6
|
+
this.status = status
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const invalidToken = (description) =>
|
|
11
|
+
new UserInfoError('invalid_token', description, 401)
|
|
12
|
+
|
|
13
|
+
const invalidDpopProof = (description) =>
|
|
14
|
+
new UserInfoError('invalid_dpop_proof', description, 401)
|
|
15
|
+
|
|
16
|
+
const invalidRequest = (description) =>
|
|
17
|
+
new UserInfoError('invalid_request', description, 400)
|
|
18
|
+
|
|
19
|
+
function sendUserInfoError(res, caught) {
|
|
20
|
+
const known = caught instanceof UserInfoError
|
|
21
|
+
const status = known ? caught.status : 500
|
|
22
|
+
const error = known ? caught.error : 'server_error'
|
|
23
|
+
const error_description = caught.message
|
|
24
|
+
|
|
25
|
+
if (status === 401) {
|
|
26
|
+
res.set(
|
|
27
|
+
'WWW-Authenticate',
|
|
28
|
+
`DPoP error="${error}", error_description="${error_description}"`,
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
return res.status(status).send({ error, error_description })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = {
|
|
35
|
+
invalidDpopProof,
|
|
36
|
+
invalidRequest,
|
|
37
|
+
invalidToken,
|
|
38
|
+
sendUserInfoError,
|
|
39
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const {
|
|
2
|
+
createHash,
|
|
2
3
|
generateKeyPairSync,
|
|
3
4
|
createPrivateKey,
|
|
4
5
|
createPublicKey,
|
|
@@ -8,13 +9,10 @@ const assert = require('assert')
|
|
|
8
9
|
const jose = require('jose')
|
|
9
10
|
const { readFileSync } = require('fs')
|
|
10
11
|
const path = require('path')
|
|
12
|
+
const assertions = require('../../assertions')
|
|
11
13
|
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
UINFIN: 'uinfin',
|
|
15
|
-
USER_IDENTITY: 'user.identity',
|
|
16
|
-
NAME: 'name',
|
|
17
|
-
}
|
|
14
|
+
const MyInfoPersonScopes = assertions.myinfo.v3.attributes.basic
|
|
15
|
+
const SupportedScopes = ['openid', 'user.identity', ...MyInfoPersonScopes]
|
|
18
16
|
const SupportedClaims = {
|
|
19
17
|
NONCE: 'nonce',
|
|
20
18
|
AUDIENCE: 'aud',
|
|
@@ -93,8 +91,9 @@ function getFapiOpenIdConfiguration(req) {
|
|
|
93
91
|
authorization_endpoint: `${issuer}/auth`,
|
|
94
92
|
jwks_uri: `${issuer}/.well-known/keys`,
|
|
95
93
|
token_endpoint: `${issuer}/token`,
|
|
94
|
+
userinfo_endpoint: `${issuer}/userinfo`,
|
|
96
95
|
response_types_supported: Object.values(SupportedResponseTypes),
|
|
97
|
-
scopes_supported:
|
|
96
|
+
scopes_supported: SupportedScopes,
|
|
98
97
|
subject_types_supported: ['public'],
|
|
99
98
|
claims_supported: Object.values(SupportedClaims),
|
|
100
99
|
grant_types_supported: Object.values(SupportedGrantTypes),
|
|
@@ -107,6 +106,13 @@ function getFapiOpenIdConfiguration(req) {
|
|
|
107
106
|
'ECDH-ES+A128KW',
|
|
108
107
|
],
|
|
109
108
|
id_token_encryption_enc_values_supported: ['A256CBC-HS512'],
|
|
109
|
+
userinfo_signing_alg_values_supported: ['ES256'],
|
|
110
|
+
userinfo_encryption_alg_values_supported: [
|
|
111
|
+
'ECDH-ES+A256KW',
|
|
112
|
+
'ECDH-ES+A192KW',
|
|
113
|
+
'ECDH-ES+A128KW',
|
|
114
|
+
],
|
|
115
|
+
userinfo_encryption_enc_values_supported: ['A256GCM'],
|
|
110
116
|
}
|
|
111
117
|
}
|
|
112
118
|
|
|
@@ -148,7 +154,12 @@ function generateKeys(ephemeralPrivateKey) {
|
|
|
148
154
|
}
|
|
149
155
|
return { publicKey, privateKey }
|
|
150
156
|
}
|
|
157
|
+
function hashAccessToken(accessToken) {
|
|
158
|
+
return createHash('sha256').update(accessToken).digest('base64url')
|
|
159
|
+
}
|
|
160
|
+
|
|
151
161
|
async function generateDpopToken(req, publicKey, privateKey) {
|
|
162
|
+
const { access_token: accessToken, method = 'POST' } = req.body
|
|
152
163
|
const dpop = {
|
|
153
164
|
max_age: dpopConfiguration.MAX_AGE,
|
|
154
165
|
header: {
|
|
@@ -163,11 +174,12 @@ async function generateDpopToken(req, publicKey, privateKey) {
|
|
|
163
174
|
},
|
|
164
175
|
body: {
|
|
165
176
|
htu: req.body.endpoint,
|
|
166
|
-
htm:
|
|
177
|
+
htm: method.toUpperCase(),
|
|
167
178
|
jti: randomUUID(),
|
|
168
179
|
iat: Math.floor(Date.now() / 1000),
|
|
169
180
|
exp: Math.floor(Date.now() / 1000) + dpopConfiguration.MAX_AGE,
|
|
170
181
|
nonce: randomUUID(),
|
|
182
|
+
...(accessToken ? { ath: hashAccessToken(accessToken) } : {}),
|
|
171
183
|
},
|
|
172
184
|
}
|
|
173
185
|
const dpopKey = createPrivateKey({
|
|
@@ -228,8 +240,10 @@ module.exports = {
|
|
|
228
240
|
fapiClientConfiguration,
|
|
229
241
|
generateDpopAndClientAssertionToken,
|
|
230
242
|
getFapiOpenIdConfiguration,
|
|
243
|
+
hashAccessToken,
|
|
231
244
|
idTokenConfiguration,
|
|
245
|
+
MyInfoPersonScopes,
|
|
232
246
|
SupportedGrantTypes,
|
|
233
247
|
SupportedResponseTypes,
|
|
234
|
-
|
|
248
|
+
SupportedScopes,
|
|
235
249
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengovsg/mockpass",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.7.0",
|
|
4
4
|
"description": "A mock SingPass/CorpPass server for dev purposes",
|
|
5
5
|
"main": "app.js",
|
|
6
6
|
"bin": {
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"uuid": "^9.0.0"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
|
-
"@commitlint/cli": "^
|
|
54
|
+
"@commitlint/cli": "^21.2.2",
|
|
55
55
|
"@commitlint/config-conventional": "^21.2.0",
|
|
56
56
|
"@commitlint/travis-cli": "^21.2.1",
|
|
57
57
|
"@eslint/eslintrc": "^3.1.0",
|