@opengovsg/mockpass 3.1.1 → 3.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.eslintrc.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "plugin:prettier/recommended"
5
5
  ],
6
6
  "parserOptions": {
7
- "ecmaVersion": 2018
7
+ "ecmaVersion": 2020
8
8
  },
9
9
  "env": {
10
10
  "node": true,
@@ -6,7 +6,7 @@ on:
6
6
  jobs:
7
7
  ci:
8
8
  name: CI
9
- runs-on: ubuntu-18.04
9
+ runs-on: ubuntu-latest
10
10
  steps:
11
11
  - uses: actions/checkout@v2
12
12
  - name: Use Node.js
package/lib/auth-code.js CHANGED
@@ -4,9 +4,9 @@ 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 generateAuthCode = ({ profile, nonce }) => {
7
+ const generateAuthCode = ({ profile, scopes, nonce }) => {
8
8
  const authCode = crypto.randomBytes(45).toString('base64')
9
- profileAndNonceStore.set(authCode, { profile, nonce })
9
+ profileAndNonceStore.set(authCode, { profile, scopes, nonce })
10
10
  return authCode
11
11
  }
12
12
 
@@ -35,11 +35,13 @@ function config(app, { showLoginPage, serviceProvider }) {
35
35
 
36
36
  app.get(`${PATH_PREFIX}/authorize`, (req, res) => {
37
37
  const { redirect_uri: redirectURI, state, nonce } = req.query
38
+ const scopes = req.query.scope ?? 'openid'
39
+ console.info(`Requested scope ${scopes}`)
38
40
  if (showLoginPage(req)) {
39
41
  const values = profiles
40
42
  .filter((profile) => assertions.myinfo.v3.personas[profile.nric])
41
43
  .map((profile) => {
42
- const authCode = generateAuthCode({ profile, nonce })
44
+ const authCode = generateAuthCode({ profile, scopes, nonce })
43
45
  const assertURL = buildAssertURL(redirectURI, authCode, state)
44
46
  const id = idGenerator.singPass(profile)
45
47
  return { id, assertURL }
@@ -48,9 +50,9 @@ function config(app, { showLoginPage, serviceProvider }) {
48
50
  res.send(response)
49
51
  } else {
50
52
  const profile = defaultProfile
51
- const authCode = generateAuthCode({ profile, nonce })
53
+ const authCode = generateAuthCode({ profile, scopes, nonce })
52
54
  const assertURL = buildAssertURL(redirectURI, authCode, state)
53
- console.warn(
55
+ console.info(
54
56
  `Redirecting login from ${req.query.client_id} to ${assertURL}`,
55
57
  )
56
58
  res.redirect(assertURL)
@@ -65,13 +67,16 @@ function config(app, { showLoginPage, serviceProvider }) {
65
67
  console.log(req.body)
66
68
  const { client_id: aud, code: authCode } = req.body
67
69
 
68
- console.warn(
70
+ console.info(
69
71
  `Received auth code ${authCode} from ${aud} and ${req.body.redirect_uri}`,
70
72
  )
71
- try {
72
- const { profile, nonce } = lookUpByAuthCode(authCode)
73
73
 
74
- const accessToken = profile.uuid
74
+ try {
75
+ const { profile, scopes, nonce } = lookUpByAuthCode(authCode)
76
+ console.info(
77
+ `Profile ${JSON.stringify(profile)} with token scope ${scopes}`,
78
+ )
79
+ const accessToken = authCode
75
80
  const iss = `${req.protocol}://${req.get('host')}`
76
81
 
77
82
  const { idTokenClaims, refreshToken } = assertions.oidc.create.singPass(
@@ -97,7 +102,7 @@ function config(app, { showLoginPage, serviceProvider }) {
97
102
  access_token: accessToken,
98
103
  refresh_token: refreshToken,
99
104
  expires_in: 24 * 60 * 60,
100
- scope: 'openid',
105
+ scope: scopes,
101
106
  token_type: 'Bearer',
102
107
  id_token: idToken,
103
108
  })
@@ -109,45 +114,44 @@ function config(app, { showLoginPage, serviceProvider }) {
109
114
  )
110
115
 
111
116
  app.get(`${PATH_PREFIX}/userinfo`, async (req, res) => {
112
- const uuid = (
117
+ const authCode = (
113
118
  req.headers.authorization || req.headers.Authorization
114
119
  ).replace('Bearer ', '')
120
+ // eslint-disable-next-line no-unused-vars
121
+ const { profile, scopes, unused } = lookUpByAuthCode(authCode)
122
+ const uuid = profile.uuid
115
123
  const nric = assertions.oidc.singPass.find((p) => p.uuid === uuid).nric
116
124
  const persona = assertions.myinfo.v3.personas[nric]
117
- const name = persona.name.value
118
- const dateOfBirth = persona.dob.value
119
125
 
126
+ console.info(`userinfo scopes ${scopes}`)
120
127
  const payloadKey = await jose.JWK.createKey('oct', 256, {
121
128
  alg: 'A256GCM',
122
129
  })
123
130
 
124
- const encryptedNric = await jose.JWE.createEncrypt(
125
- { format: 'compact' },
126
- payloadKey,
127
- )
128
- .update(nric)
129
- .final()
130
- const encryptedName = await jose.JWE.createEncrypt(
131
- { format: 'compact' },
132
- payloadKey,
133
- )
134
- .update(name)
135
- .final()
136
- const encryptedDateOfBirth = await jose.JWE.createEncrypt(
137
- { format: 'compact' },
138
- payloadKey,
139
- )
140
- .update(dateOfBirth)
141
- .final()
142
- const data = {
143
- 'myinfo.nric_number': encryptedNric,
144
- 'myinfo.name': encryptedName,
145
- 'myinfo.date_of_birth': encryptedDateOfBirth,
131
+ const encryptPayload = async (field) => {
132
+ return await jose.JWE.createEncrypt({ format: 'compact' }, payloadKey)
133
+ .update(field)
134
+ .final()
146
135
  }
136
+ const encryptedNric = await encryptPayload(nric)
137
+ const scopesArr = scopes
138
+ .split(' ')
139
+ .filter((field) => field !== 'openid' && field !== 'myinfo.nric_number')
140
+ console.info(`userinfo scopesArr ${scopesArr}`)
141
+ const myInfoFields = await Promise.all(
142
+ scopesArr.map((scope) =>
143
+ encryptPayload(sgIDScopeToMyInfoField(persona, scope)),
144
+ ),
145
+ )
146
+
147
+ const data = {}
148
+ scopesArr.forEach((name, index) => {
149
+ data[name] = myInfoFields[index]
150
+ })
151
+ data['myinfo.nric_number'] = encryptedNric
147
152
  const encryptionKey = await jose.JWK.asKey(serviceProvider.pubKey, 'pem')
148
153
 
149
154
  const plaintextPayloadKey = JSON.stringify(payloadKey.toJSON(true))
150
- console.log(plaintextPayloadKey)
151
155
  const encryptedPayloadKey = await jose.JWE.createEncrypt(
152
156
  { format: 'compact' },
153
157
  encryptionKey,
@@ -167,6 +171,82 @@ function config(app, { showLoginPage, serviceProvider }) {
167
171
  jwk.use = 'sig'
168
172
  res.json({ keys: [jwk] })
169
173
  })
174
+
175
+ app.get('/.well-known/openid-configuration', async (req, res) => {
176
+ const issuer = `${req.protocol}://${req.get('host')}`
177
+
178
+ res.json({
179
+ issuer,
180
+ authorization_endpoint: `${issuer}/${PATH_PREFIX}/authorize`,
181
+ token_endpoint: `${issuer}/${PATH_PREFIX}/token`,
182
+ userinfo_endpoint: `${issuer}/${PATH_PREFIX}/userinfo`,
183
+ jwks_uri: `${issuer}/.well-known/jwks.json`,
184
+ response_types_supported: ['code'],
185
+ grant_types_supported: ['authorization_code'],
186
+ // Note: some of these scopes are not yet officially documented
187
+ // in https://docs.id.gov.sg/data-catalog
188
+ // So they are not officially supported yet.
189
+ scopes_supported: [
190
+ 'openid',
191
+ 'myinfo.nric_number',
192
+ 'myinfo.name',
193
+ 'myinfo.email',
194
+ 'myinfo.sex',
195
+ 'myinfo.race',
196
+ 'myinfo.mobile_number',
197
+ 'myinfo.registered_address',
198
+ 'myinfo.date_of_birth',
199
+ 'myinfo.passport_number',
200
+ 'myinfo.passport_expiry_date',
201
+ 'myinfo.nationality',
202
+ 'myinfo.residentialstatus',
203
+ 'myinfo.residential',
204
+ 'myinfo.housingtype',
205
+ 'myinfo.hdbtype',
206
+ ],
207
+ id_token_signing_alg_values_supported: ['RS256'],
208
+ subject_types_supported: ['pairwise'],
209
+ })
210
+ })
211
+ }
212
+
213
+ const concatMyInfoRegAddr = (regadd) => {
214
+ const line1 =
215
+ !!regadd.block.value || !!regadd.street.value
216
+ ? `${regadd.block.value} ${regadd.street.value}`
217
+ : ''
218
+ const line2 =
219
+ !!regadd.floor.value || !!regadd.unit.value
220
+ ? `#${regadd.floor.value}-${regadd.unit.value}`
221
+ : ''
222
+ const line3 =
223
+ !!regadd.country.desc || !!regadd.postal.value
224
+ ? `${regadd.country.desc} ${regadd.postal.value}`
225
+ : ''
226
+ return `${line1}\n${line2}\n${line3}`
227
+ }
228
+
229
+ // Refer to https://docs.id.gov.sg/data-catalog
230
+ const sgIDScopeToMyInfoField = (persona, scope) => {
231
+ switch (scope) {
232
+ // No NRIC as that is always returned by default
233
+ case 'myinfo.name':
234
+ return persona.name.value
235
+ case 'myinfo.email':
236
+ return persona.email.value
237
+ case 'myinfo.mobile_number':
238
+ return persona.mobileno.nbr.value
239
+ case 'myinfo.registered_address':
240
+ return concatMyInfoRegAddr(persona.regadd)
241
+ case 'myinfo.date_of_birth':
242
+ return persona.dob.value
243
+ case 'myinfo.passport_number':
244
+ return persona.passportnumber.value
245
+ case 'myinfo.passport_expiry_date':
246
+ return persona.passportexpirydate.value
247
+ default:
248
+ return ''
249
+ }
170
250
  }
171
251
 
172
252
  module.exports = config
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengovsg/mockpass",
3
- "version": "3.1.1",
3
+ "version": "3.1.3",
4
4
  "description": "A mock SingPass/CorpPass server for dev purposes",
5
5
  "main": "index.js",
6
6
  "bin": {