@opengovsg/mockpass 3.1.2 → 4.0.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/.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,
package/README.md CHANGED
@@ -44,9 +44,11 @@ MyInfo:
44
44
  - http://localhost:5156/myinfo/v3/person
45
45
 
46
46
  sgID:
47
- - http://localhost:5156/sgid/v1/oauth/authorize
48
- - http://localhost:5156/sgid/v1/oauth/token
49
- - http://localhost:5156/sgid/v1/oauth/userinfo
47
+ - http://localhost:5156/v2/oauth/authorize
48
+ - http://localhost:5156/v2/oauth/token
49
+ - http://localhost:5156/v2/oauth/userinfo
50
+ - http://localhost:5156/v2/.well-known/openid-configuration - OpenID discovery endpoint
51
+ - http://localhost:5156/v2/.well-known/jwks.json - JWKS endpoint which exposes the auth provider's signing keys
50
52
 
51
53
  Provide your application with the `spcp*` certs found in `static/certs`
52
54
  and with application certs at `static/certs/{key.pem|server.crt}`
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
 
@@ -12,7 +12,9 @@ const LOGIN_TEMPLATE = fs.readFileSync(
12
12
  'utf8',
13
13
  )
14
14
 
15
- const PATH_PREFIX = '/sgid/v1/oauth'
15
+ const VERSION_PREFIX = '/v2'
16
+ const OAUTH_PREFIX = '/oauth'
17
+ const PATH_PREFIX = VERSION_PREFIX + OAUTH_PREFIX
16
18
 
17
19
  const signingPem = fs.readFileSync(
18
20
  path.resolve(__dirname, '../../static/certs/spcp-key.pem'),
@@ -35,11 +37,13 @@ function config(app, { showLoginPage, serviceProvider }) {
35
37
 
36
38
  app.get(`${PATH_PREFIX}/authorize`, (req, res) => {
37
39
  const { redirect_uri: redirectURI, state, nonce } = req.query
40
+ const scopes = req.query.scope ?? 'openid'
41
+ console.info(`Requested scope ${scopes}`)
38
42
  if (showLoginPage(req)) {
39
43
  const values = profiles
40
44
  .filter((profile) => assertions.myinfo.v3.personas[profile.nric])
41
45
  .map((profile) => {
42
- const authCode = generateAuthCode({ profile, nonce })
46
+ const authCode = generateAuthCode({ profile, scopes, nonce })
43
47
  const assertURL = buildAssertURL(redirectURI, authCode, state)
44
48
  const id = idGenerator.singPass(profile)
45
49
  return { id, assertURL }
@@ -48,9 +52,9 @@ function config(app, { showLoginPage, serviceProvider }) {
48
52
  res.send(response)
49
53
  } else {
50
54
  const profile = defaultProfile
51
- const authCode = generateAuthCode({ profile, nonce })
55
+ const authCode = generateAuthCode({ profile, scopes, nonce })
52
56
  const assertURL = buildAssertURL(redirectURI, authCode, state)
53
- console.warn(
57
+ console.info(
54
58
  `Redirecting login from ${req.query.client_id} to ${assertURL}`,
55
59
  )
56
60
  res.redirect(assertURL)
@@ -65,14 +69,17 @@ function config(app, { showLoginPage, serviceProvider }) {
65
69
  console.log(req.body)
66
70
  const { client_id: aud, code: authCode } = req.body
67
71
 
68
- console.warn(
72
+ console.info(
69
73
  `Received auth code ${authCode} from ${aud} and ${req.body.redirect_uri}`,
70
74
  )
71
- try {
72
- const { profile, nonce } = lookUpByAuthCode(authCode)
73
75
 
74
- const accessToken = profile.uuid
75
- const iss = `${req.protocol}://${req.get('host')}`
76
+ try {
77
+ const { profile, scopes, nonce } = lookUpByAuthCode(authCode)
78
+ console.info(
79
+ `Profile ${JSON.stringify(profile)} with token scope ${scopes}`,
80
+ )
81
+ const accessToken = authCode
82
+ const iss = `${req.protocol}://${req.get('host') + VERSION_PREFIX}`
76
83
 
77
84
  const { idTokenClaims, refreshToken } = assertions.oidc.create.singPass(
78
85
  profile,
@@ -97,7 +104,7 @@ function config(app, { showLoginPage, serviceProvider }) {
97
104
  access_token: accessToken,
98
105
  refresh_token: refreshToken,
99
106
  expires_in: 24 * 60 * 60,
100
- scope: 'openid',
107
+ scope: scopes,
101
108
  token_type: 'Bearer',
102
109
  id_token: idToken,
103
110
  })
@@ -109,45 +116,44 @@ function config(app, { showLoginPage, serviceProvider }) {
109
116
  )
110
117
 
111
118
  app.get(`${PATH_PREFIX}/userinfo`, async (req, res) => {
112
- const uuid = (
119
+ const authCode = (
113
120
  req.headers.authorization || req.headers.Authorization
114
121
  ).replace('Bearer ', '')
122
+ // eslint-disable-next-line no-unused-vars
123
+ const { profile, scopes, unused } = lookUpByAuthCode(authCode)
124
+ const uuid = profile.uuid
115
125
  const nric = assertions.oidc.singPass.find((p) => p.uuid === uuid).nric
116
126
  const persona = assertions.myinfo.v3.personas[nric]
117
- const name = persona.name.value
118
- const dateOfBirth = persona.dob.value
119
127
 
128
+ console.info(`userinfo scopes ${scopes}`)
120
129
  const payloadKey = await jose.JWK.createKey('oct', 256, {
121
130
  alg: 'A256GCM',
122
131
  })
123
132
 
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,
133
+ const encryptPayload = async (field) => {
134
+ return await jose.JWE.createEncrypt({ format: 'compact' }, payloadKey)
135
+ .update(field)
136
+ .final()
146
137
  }
138
+ const encryptedNric = await encryptPayload(nric)
139
+ const scopesArr = scopes
140
+ .split(' ')
141
+ .filter((field) => field !== 'openid' && field !== 'myinfo.nric_number')
142
+ console.info(`userinfo scopesArr ${scopesArr}`)
143
+ const myInfoFields = await Promise.all(
144
+ scopesArr.map((scope) =>
145
+ encryptPayload(sgIDScopeToMyInfoField(persona, scope)),
146
+ ),
147
+ )
148
+
149
+ const data = {}
150
+ scopesArr.forEach((name, index) => {
151
+ data[name] = myInfoFields[index]
152
+ })
153
+ data['myinfo.nric_number'] = encryptedNric
147
154
  const encryptionKey = await jose.JWK.asKey(serviceProvider.pubKey, 'pem')
148
155
 
149
156
  const plaintextPayloadKey = JSON.stringify(payloadKey.toJSON(true))
150
- console.log(plaintextPayloadKey)
151
157
  const encryptedPayloadKey = await jose.JWE.createEncrypt(
152
158
  { format: 'compact' },
153
159
  encryptionKey,
@@ -161,46 +167,91 @@ function config(app, { showLoginPage, serviceProvider }) {
161
167
  })
162
168
  })
163
169
 
164
- app.get('/.well-known/jwks.json', async (_req, res) => {
170
+ app.get(`${VERSION_PREFIX}/.well-known/jwks.json`, async (_req, res) => {
165
171
  const key = await jose.JWK.asKey(signingPem, 'pem')
166
172
  const jwk = key.toJSON()
167
173
  jwk.use = 'sig'
168
174
  res.json({ keys: [jwk] })
169
175
  })
170
176
 
171
- app.get('/.well-known/openid-configuration', async (req, res) => {
172
- const issuer = `${req.protocol}://${req.get('host')}`
177
+ app.get(
178
+ `${VERSION_PREFIX}/.well-known/openid-configuration`,
179
+ async (req, res) => {
180
+ const issuer = `${req.protocol}://${req.get('host') + VERSION_PREFIX}`
173
181
 
174
- res.json({
175
- issuer,
176
- authorization_endpoint: `${issuer}/${PATH_PREFIX}/authorize`,
177
- token_endpoint: `${issuer}/${PATH_PREFIX}/token`,
178
- userinfo_endpoint: `${issuer}/${PATH_PREFIX}/userinfo`,
179
- jwks_uri: `${issuer}/.well-known/jwks.json`,
180
- response_types_supported: ['code'],
181
- grant_types_supported: ['authorization_code'],
182
- scopes_supported: [
183
- 'openid',
184
- 'myinfo.nric_number',
185
- 'myinfo.name',
186
- 'myinfo.email',
187
- 'myinfo.sex',
188
- 'myinfo.race',
189
- 'myinfo.mobile_number',
190
- 'myinfo.registered_address',
191
- 'myinfo.date_of_birth',
192
- 'myinfo.passport_number',
193
- 'myinfo.passport_expiry_date',
194
- 'myinfo.nationality',
195
- 'myinfo.residentialstatus',
196
- 'myinfo.residential',
197
- 'myinfo.housingtype',
198
- 'myinfo.hdbtype',
199
- ],
200
- id_token_signing_alg_values_supported: ['RS256'],
201
- subject_types_supported: ['pairwise'],
202
- })
203
- })
182
+ res.json({
183
+ issuer,
184
+ authorization_endpoint: `${issuer}/${OAUTH_PREFIX}/authorize`,
185
+ token_endpoint: `${issuer}/${OAUTH_PREFIX}/token`,
186
+ userinfo_endpoint: `${issuer}/${OAUTH_PREFIX}/userinfo`,
187
+ jwks_uri: `${issuer}/.well-known/jwks.json`,
188
+ response_types_supported: ['code'],
189
+ grant_types_supported: ['authorization_code'],
190
+ // Note: some of these scopes are not yet officially documented
191
+ // in https://docs.id.gov.sg/data-catalog
192
+ // So they are not officially supported yet.
193
+ scopes_supported: [
194
+ 'openid',
195
+ 'myinfo.nric_number',
196
+ 'myinfo.name',
197
+ 'myinfo.email',
198
+ 'myinfo.sex',
199
+ 'myinfo.race',
200
+ 'myinfo.mobile_number',
201
+ 'myinfo.registered_address',
202
+ 'myinfo.date_of_birth',
203
+ 'myinfo.passport_number',
204
+ 'myinfo.passport_expiry_date',
205
+ 'myinfo.nationality',
206
+ 'myinfo.residentialstatus',
207
+ 'myinfo.residential',
208
+ 'myinfo.housingtype',
209
+ 'myinfo.hdbtype',
210
+ ],
211
+ id_token_signing_alg_values_supported: ['RS256'],
212
+ subject_types_supported: ['pairwise'],
213
+ })
214
+ },
215
+ )
216
+ }
217
+
218
+ const concatMyInfoRegAddr = (regadd) => {
219
+ const line1 =
220
+ !!regadd.block.value || !!regadd.street.value
221
+ ? `${regadd.block.value} ${regadd.street.value}`
222
+ : ''
223
+ const line2 =
224
+ !!regadd.floor.value || !!regadd.unit.value
225
+ ? `#${regadd.floor.value}-${regadd.unit.value}`
226
+ : ''
227
+ const line3 =
228
+ !!regadd.country.desc || !!regadd.postal.value
229
+ ? `${regadd.country.desc} ${regadd.postal.value}`
230
+ : ''
231
+ return `${line1}\n${line2}\n${line3}`
232
+ }
233
+
234
+ // Refer to https://docs.id.gov.sg/data-catalog
235
+ const sgIDScopeToMyInfoField = (persona, scope) => {
236
+ switch (scope) {
237
+ // No NRIC as that is always returned by default
238
+ case 'myinfo.name':
239
+ return persona.name.value
240
+ case 'myinfo.email':
241
+ return persona.email.value
242
+ case 'myinfo.mobile_number':
243
+ return persona.mobileno.nbr.value
244
+ case 'myinfo.registered_address':
245
+ return concatMyInfoRegAddr(persona.regadd)
246
+ case 'myinfo.date_of_birth':
247
+ return persona.dob.value
248
+ case 'myinfo.passport_number':
249
+ return persona.passportnumber.value
250
+ case 'myinfo.passport_expiry_date':
251
+ return persona.passportexpirydate.value
252
+ default:
253
+ return ''
254
+ }
204
255
  }
205
256
 
206
257
  module.exports = config
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengovsg/mockpass",
3
- "version": "3.1.2",
3
+ "version": "4.0.0",
4
4
  "description": "A mock SingPass/CorpPass server for dev purposes",
5
5
  "main": "index.js",
6
6
  "bin": {