@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 +1 -1
- package/README.md +5 -3
- package/lib/auth-code.js +2 -2
- package/lib/express/sgid.js +120 -69
- package/package.json +1 -1
package/.eslintrc.json
CHANGED
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/
|
|
48
|
-
- http://localhost:5156/
|
|
49
|
-
- http://localhost:5156/
|
|
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
|
|
package/lib/express/sgid.js
CHANGED
|
@@ -12,7 +12,9 @@ const LOGIN_TEMPLATE = fs.readFileSync(
|
|
|
12
12
|
'utf8',
|
|
13
13
|
)
|
|
14
14
|
|
|
15
|
-
const
|
|
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.
|
|
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.
|
|
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
|
-
|
|
75
|
-
const
|
|
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:
|
|
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
|
|
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
|
|
125
|
-
{ format: 'compact' },
|
|
126
|
-
|
|
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(
|
|
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(
|
|
172
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|