@opengovsg/mockpass 4.6.11 → 4.7.1

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
@@ -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
- - Only Login is supported for now. Userinfo endpoint is not supported.
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. | ``` -----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` |
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
- module.exports = { generateAuthCode, generateAuthCodeForFapi, lookUpByAuthCode }
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 { lookUpByAuthCode, generateAuthCodeForFapi } = require('../../auth-code')
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(req.headers['dpop'], authRequest.dpopJkt, tokenEndpoint)
73
- return await this.generateIdToken(req, authRequest)
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 generateIdToken(req, authRequest) {
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
- //Sign Id Token
148
- const signingKid = FapiUtils.FAPI_ASP_SIGNING_KID
149
- const fapiPrivateJwks = JSON.parse(
150
- fs.readFileSync(
151
- path.resolve(__dirname, FapiUtils.FAPI_ASP_PRIVATE_JWKS_PATH),
152
- ),
153
- )
154
- const fapiSigningPrivateKey = fapiPrivateJwks.keys.find(
155
- (item) => item.use === 'sig' && item.kid === signingKid,
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
- const signedIdToken = await new jose.CompactSign(
167
- new TextEncoder().encode(JSON.stringify(id_token)),
168
- )
169
- .setProtectedHeader(signedProtectedHeader)
170
- .sign(signingKey)
171
-
172
- //Encrypt signed id token
173
- let rpEncPublicKey
174
- if (FapiUtils.fapiClientConfiguration.client_jwks) {
175
- const jwks = await fetch(
176
- FapiUtils.fapiClientConfiguration.client_jwks,
177
- ).then((res) => res.json())
178
- if (!jwks.keys) throw new Error('Unable to fetch client jwks')
179
- rpEncPublicKey = jwks.keys.find((key) => key.use === 'enc')
180
- } else {
181
- rpEncPublicKey = JSON.parse(
182
- readFileSync(
183
- path.resolve(__dirname, FapiUtils.FAPI_RP_PUBLIC_JWKS_PATH),
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
- if (!rpEncPublicKey) throw new Error('No suitable encryption key found')
188
- const encKey = await jose.importJWK(rpEncPublicKey)
189
- const encrpytionProtectedHeader = {
190
- alg: rpEncPublicKey.alg,
191
- typ: 'JWT',
192
- kid: rpEncPublicKey.kid,
193
- enc: 'A256GCM',
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
- return {
203
- access_token: crypto.randomBytes(64).toString('base64url'),
204
- id_token: encryptedIdToken,
205
- token_type: 'DPoP',
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(dpop, dpop_jkt, expectedEndpoint) {
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: ['jti', 'iat', 'htu', 'htm'],
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
- // Extract payload from DPoP token
229
- const payloadB64 = dpopToken.split('.')[1]
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() !== 'POST') {
239
- throw new Error('Only POST requests are supported with DPoP')
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 (!Object.values(FapiUtils.SupportedScope).includes(s))
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 SupportedScope = {
13
- OPENID: 'openid',
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: Object.values(SupportedScope),
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: 'POST',
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
- SupportedScope,
248
+ SupportedScopes,
235
249
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengovsg/mockpass",
3
- "version": "4.6.11",
3
+ "version": "4.7.1",
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": "^20.1.0",
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",