@opengovsg/mockpass 4.0.12 → 4.3.2
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/.github/workflows/npmpublish.yml +5 -3
- package/.husky/install.mjs +6 -0
- package/.husky/pre-commit +0 -3
- package/.husky/pre-push +0 -3
- package/Dockerfile +2 -0
- package/README.md +12 -2
- package/app.js +4 -1
- package/lib/auth-code.js +14 -4
- package/lib/express/myinfo/consent.js +2 -2
- package/lib/express/oidc/spcp.js +20 -8
- package/lib/express/oidc/v2-ndi.js +5 -5
- package/lib/express/sgid.js +21 -5
- package/package.json +9 -9
- package/static/myinfo/v3.json +26 -17
|
@@ -11,10 +11,12 @@ jobs:
|
|
|
11
11
|
publish-npm:
|
|
12
12
|
runs-on: ubuntu-latest
|
|
13
13
|
steps:
|
|
14
|
-
- uses: actions/checkout@
|
|
15
|
-
- uses: actions/setup-node@
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: actions/setup-node@v3
|
|
16
16
|
with:
|
|
17
|
-
node-version:
|
|
17
|
+
node-version: 'lts/*'
|
|
18
|
+
cache: 'npm'
|
|
19
|
+
cache-dependency-path: '**/package-lock.json'
|
|
18
20
|
registry-url: https://registry.npmjs.org/
|
|
19
21
|
- run: npm ci
|
|
20
22
|
- run: npm publish --access public
|
package/.husky/pre-commit
CHANGED
package/.husky/pre-push
CHANGED
package/Dockerfile
CHANGED
package/README.md
CHANGED
|
@@ -84,7 +84,7 @@ Configure your application (or MockPass) with certificates/keys:
|
|
|
84
84
|
MockPass accepts any value for `client_id`, `redirect_uri` and `sp_esvcId`.
|
|
85
85
|
The `client_secret` value will be checked if configured, see below.
|
|
86
86
|
|
|
87
|
-
Only the profiles (NRICs) that have entries in Mockpass'
|
|
87
|
+
Only the profiles (NRICs) that have entries in Mockpass' personas dataset will
|
|
88
88
|
succeed, using other NRICs will result in an error. See the list of personas in
|
|
89
89
|
[static/myinfo/v3.json](static/myinfo/v3.json).
|
|
90
90
|
|
|
@@ -119,10 +119,19 @@ Configure your application (or MockPass) with certificates/keys:
|
|
|
119
119
|
|
|
120
120
|
MockPass accepts any value for `client_id`, `client_secret` and `redirect_uri`.
|
|
121
121
|
|
|
122
|
-
Only the profiles (NRICs) that have entries in Mockpass'
|
|
122
|
+
Only the profiles (NRICs) that have entries in Mockpass' personas dataset will
|
|
123
123
|
succeed, using other NRICs will result in an error. See the list of personas in
|
|
124
124
|
[static/myinfo/v3.json](static/myinfo/v3.json).
|
|
125
125
|
|
|
126
|
+
If the Public Officer Employment Details data item is requested, the
|
|
127
|
+
`pocdex.public_officer_details` scope data is sourced from the
|
|
128
|
+
`publicofficerdetails` data key (where present) on personas.
|
|
129
|
+
Most personas do not have this data key configured, and will result in a `"NA"`
|
|
130
|
+
response instead of an stringified array. As these personas are not identified
|
|
131
|
+
in the login page dropdown, please check the personas dataset linked above to
|
|
132
|
+
identify them.
|
|
133
|
+
The `pocdex.number_of_employments` scope is not supported.
|
|
134
|
+
|
|
126
135
|
| Configuration item | Explanation |
|
|
127
136
|
|---|---|
|
|
128
137
|
| 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.) |
|
|
@@ -159,6 +168,7 @@ Common configuration:
|
|
|
159
168
|
| Configuration item | Explanation |
|
|
160
169
|
|---|---|
|
|
161
170
|
| 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. |
|
|
171
|
+
| 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`. |
|
|
162
172
|
|
|
163
173
|
Run MockPass:
|
|
164
174
|
|
package/app.js
CHANGED
|
@@ -35,12 +35,15 @@ const cryptoConfig = {
|
|
|
35
35
|
process.env.RESOLVE_ARTIFACT_REQUEST_SIGNED !== 'false',
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
const isStateless = process.env.MOCKPASS_STATELESS === 'true'
|
|
39
|
+
|
|
38
40
|
const options = {
|
|
39
41
|
serviceProvider,
|
|
40
42
|
showLoginPage: (req) =>
|
|
41
43
|
(req.header('X-Show-Login-Page') || process.env.SHOW_LOGIN_PAGE) === 'true',
|
|
42
44
|
encryptMyInfo: process.env.ENCRYPT_MYINFO === 'true',
|
|
43
45
|
cryptoConfig,
|
|
46
|
+
isStateless,
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
const app = express()
|
|
@@ -50,7 +53,7 @@ configOIDC(app, options)
|
|
|
50
53
|
configOIDCv2(app, options)
|
|
51
54
|
configSGID(app, options)
|
|
52
55
|
|
|
53
|
-
configMyInfo.consent(app)
|
|
56
|
+
configMyInfo.consent(app, options)
|
|
54
57
|
configMyInfo.v3(app, options)
|
|
55
58
|
|
|
56
59
|
app.enable('trust proxy')
|
package/lib/auth-code.js
CHANGED
|
@@ -4,14 +4,24 @@ 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 = (
|
|
8
|
-
|
|
7
|
+
const generateAuthCode = (
|
|
8
|
+
{ profile, scopes, nonce },
|
|
9
|
+
{ isStateless = false },
|
|
10
|
+
) => {
|
|
11
|
+
const authCode = isStateless
|
|
12
|
+
? Buffer.from(JSON.stringify({ profile, scopes, nonce })).toString(
|
|
13
|
+
'base64url',
|
|
14
|
+
)
|
|
15
|
+
: crypto.randomBytes(45).toString('base64')
|
|
16
|
+
|
|
9
17
|
profileAndNonceStore.set(authCode, { profile, scopes, nonce })
|
|
10
18
|
return authCode
|
|
11
19
|
}
|
|
12
20
|
|
|
13
|
-
const lookUpByAuthCode = (authCode) => {
|
|
14
|
-
return
|
|
21
|
+
const lookUpByAuthCode = (authCode, { isStateless = false }) => {
|
|
22
|
+
return isStateless
|
|
23
|
+
? JSON.parse(Buffer.from(authCode, 'base64url').toString('utf-8'))
|
|
24
|
+
: profileAndNonceStore.get(authCode)
|
|
15
25
|
}
|
|
16
26
|
|
|
17
27
|
module.exports = { generateAuthCode, lookUpByAuthCode }
|
|
@@ -47,13 +47,13 @@ const authorizeViaOIDC = authorize(
|
|
|
47
47
|
`/singpass/authorize?client_id=MYINFO-CONSENTPLATFORM&redirect_uri=${MYINFO_ASSERT_ENDPOINT}&state=${state}`,
|
|
48
48
|
)
|
|
49
49
|
|
|
50
|
-
function config(app) {
|
|
50
|
+
function config(app, { isStateless }) {
|
|
51
51
|
app.get(MYINFO_ASSERT_ENDPOINT, (req, res) => {
|
|
52
52
|
const rawArtifact = req.query.SAMLart || req.query.code
|
|
53
53
|
const artifact = rawArtifact.replace(/ /g, '+')
|
|
54
54
|
const state = req.query.RelayState || req.query.state
|
|
55
55
|
|
|
56
|
-
const profile = lookUpByAuthCode(artifact).profile
|
|
56
|
+
const profile = lookUpByAuthCode(artifact, { isStateless }).profile
|
|
57
57
|
const myinfoVersion = 'v3'
|
|
58
58
|
|
|
59
59
|
const { nric: id } = profile
|
package/lib/express/oidc/spcp.js
CHANGED
|
@@ -24,7 +24,7 @@ const signingPem = fs.readFileSync(
|
|
|
24
24
|
path.resolve(__dirname, '../../../static/certs/spcp-key.pem'),
|
|
25
25
|
)
|
|
26
26
|
|
|
27
|
-
function config(app, { showLoginPage, serviceProvider }) {
|
|
27
|
+
function config(app, { showLoginPage, serviceProvider, isStateless }) {
|
|
28
28
|
for (const idp of ['singPass', 'corpPass']) {
|
|
29
29
|
const profiles = assertions.oidc[idp]
|
|
30
30
|
const defaultProfile =
|
|
@@ -34,7 +34,7 @@ function config(app, { showLoginPage, serviceProvider }) {
|
|
|
34
34
|
const { redirect_uri: redirectURI, state, nonce } = req.query
|
|
35
35
|
if (showLoginPage(req)) {
|
|
36
36
|
const values = profiles.map((profile) => {
|
|
37
|
-
const authCode = generateAuthCode({ profile, nonce })
|
|
37
|
+
const authCode = generateAuthCode({ profile, nonce }, { isStateless })
|
|
38
38
|
const assertURL = buildAssertURL(redirectURI, authCode, state)
|
|
39
39
|
const id = idGenerator[idp](profile)
|
|
40
40
|
return { id, assertURL }
|
|
@@ -53,7 +53,7 @@ function config(app, { showLoginPage, serviceProvider }) {
|
|
|
53
53
|
res.send(response)
|
|
54
54
|
} else {
|
|
55
55
|
const profile = customProfileFromHeaders[idp](req) || defaultProfile
|
|
56
|
-
const authCode = generateAuthCode({ profile, nonce })
|
|
56
|
+
const authCode = generateAuthCode({ profile, nonce }, { isStateless })
|
|
57
57
|
const assertURL = buildAssertURL(redirectURI, authCode, state)
|
|
58
58
|
console.warn(
|
|
59
59
|
`Redirecting login from ${req.query.client_id} to ${redirectURI}`,
|
|
@@ -72,7 +72,7 @@ function config(app, { showLoginPage, serviceProvider }) {
|
|
|
72
72
|
profile.uen = uen
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
const authCode = generateAuthCode({ profile, nonce })
|
|
75
|
+
const authCode = generateAuthCode({ profile, nonce }, { isStateless })
|
|
76
76
|
const assertURL = buildAssertURL(redirectURI, authCode, state)
|
|
77
77
|
res.redirect(assertURL)
|
|
78
78
|
})
|
|
@@ -88,20 +88,32 @@ function config(app, { showLoginPage, serviceProvider }) {
|
|
|
88
88
|
const { refresh_token: suppliedRefreshToken } = req.body
|
|
89
89
|
console.warn(`Refreshing tokens with ${suppliedRefreshToken}`)
|
|
90
90
|
|
|
91
|
-
profile =
|
|
91
|
+
profile = isStateless
|
|
92
|
+
? JSON.parse(
|
|
93
|
+
Buffer.from(suppliedRefreshToken, 'base64url').toString(
|
|
94
|
+
'utf-8',
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
: profileStore.get(suppliedRefreshToken)
|
|
92
98
|
} else {
|
|
93
99
|
const { code: authCode } = req.body
|
|
94
100
|
console.warn(
|
|
95
101
|
`Received auth code ${authCode} from ${aud} and ${req.body.redirect_uri}`,
|
|
96
102
|
)
|
|
97
|
-
;({ profile, nonce } = lookUpByAuthCode(authCode))
|
|
103
|
+
;({ profile, nonce } = lookUpByAuthCode(authCode, { isStateless }))
|
|
98
104
|
}
|
|
99
105
|
|
|
100
106
|
const iss = `${req.protocol}://${req.get('host')}`
|
|
101
107
|
|
|
102
|
-
const {
|
|
103
|
-
|
|
108
|
+
const {
|
|
109
|
+
idTokenClaims,
|
|
110
|
+
accessToken,
|
|
111
|
+
refreshToken: generatedRefreshToken,
|
|
112
|
+
} = await assertions.oidc.create[idp](profile, iss, aud, nonce)
|
|
104
113
|
|
|
114
|
+
const refreshToken = isStateless
|
|
115
|
+
? Buffer.from(JSON.stringify(profile)).toString('base64url')
|
|
116
|
+
: generatedRefreshToken
|
|
105
117
|
profileStore.set(refreshToken, profile)
|
|
106
118
|
|
|
107
119
|
const signingKey = await jose.JWK.asKey(signingPem, 'pem')
|
|
@@ -140,7 +140,7 @@ function findEncryptionKey(jwks, algs) {
|
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
function config(app, { showLoginPage }) {
|
|
143
|
+
function config(app, { showLoginPage, isStateless }) {
|
|
144
144
|
for (const idp of ['singPass', 'corpPass']) {
|
|
145
145
|
const profiles = assertions.oidc[idp]
|
|
146
146
|
const defaultProfile =
|
|
@@ -196,7 +196,7 @@ function config(app, { showLoginPage }) {
|
|
|
196
196
|
// Identical to OIDC v1
|
|
197
197
|
if (showLoginPage(req)) {
|
|
198
198
|
const values = profiles.map((profile) => {
|
|
199
|
-
const authCode = generateAuthCode({ profile, nonce })
|
|
199
|
+
const authCode = generateAuthCode({ profile, nonce }, { isStateless })
|
|
200
200
|
const assertURL = buildAssertURL(redirectURI, authCode, state)
|
|
201
201
|
const id = idGenerator[idp](profile)
|
|
202
202
|
return { id, assertURL }
|
|
@@ -215,7 +215,7 @@ function config(app, { showLoginPage }) {
|
|
|
215
215
|
res.send(response)
|
|
216
216
|
} else {
|
|
217
217
|
const profile = customProfileFromHeaders[idp](req) || defaultProfile
|
|
218
|
-
const authCode = generateAuthCode({ profile, nonce })
|
|
218
|
+
const authCode = generateAuthCode({ profile, nonce }, { isStateless })
|
|
219
219
|
const assertURL = buildAssertURL(redirectURI, authCode, state)
|
|
220
220
|
console.warn(
|
|
221
221
|
`Redirecting login from ${req.query.client_id} to ${redirectURI}`,
|
|
@@ -234,7 +234,7 @@ function config(app, { showLoginPage }) {
|
|
|
234
234
|
profile.uen = uen
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
-
const authCode = generateAuthCode({ profile, nonce })
|
|
237
|
+
const authCode = generateAuthCode({ profile, nonce }, { isStateless })
|
|
238
238
|
const assertURL = buildAssertURL(redirectURI, authCode, state)
|
|
239
239
|
res.redirect(assertURL)
|
|
240
240
|
})
|
|
@@ -434,7 +434,7 @@ function config(app, { showLoginPage }) {
|
|
|
434
434
|
}
|
|
435
435
|
|
|
436
436
|
// Step 1: Obtain profile for which the auth code requested data for
|
|
437
|
-
const { profile, nonce } = lookUpByAuthCode(authCode)
|
|
437
|
+
const { profile, nonce } = lookUpByAuthCode(authCode, { isStateless })
|
|
438
438
|
|
|
439
439
|
// Step 2: Get ID token
|
|
440
440
|
const aud = clientAssertionClaims['sub']
|
package/lib/express/sgid.js
CHANGED
|
@@ -30,7 +30,7 @@ const buildAssertURL = (redirectURI, authCode, state) =>
|
|
|
30
30
|
authCode,
|
|
31
31
|
)}&state=${encodeURIComponent(state)}`
|
|
32
32
|
|
|
33
|
-
function config(app, { showLoginPage, serviceProvider }) {
|
|
33
|
+
function config(app, { showLoginPage, serviceProvider, isStateless }) {
|
|
34
34
|
const profiles = assertions.oidc.singPass
|
|
35
35
|
const defaultProfile =
|
|
36
36
|
profiles.find((p) => p.nric === process.env.MOCKPASS_NRIC) || profiles[0]
|
|
@@ -43,7 +43,10 @@ function config(app, { showLoginPage, serviceProvider }) {
|
|
|
43
43
|
const values = profiles
|
|
44
44
|
.filter((profile) => assertions.myinfo.v3.personas[profile.nric])
|
|
45
45
|
.map((profile) => {
|
|
46
|
-
const authCode = generateAuthCode(
|
|
46
|
+
const authCode = generateAuthCode(
|
|
47
|
+
{ profile, scopes, nonce },
|
|
48
|
+
{ isStateless },
|
|
49
|
+
)
|
|
47
50
|
const assertURL = buildAssertURL(redirectURI, authCode, state)
|
|
48
51
|
const id = idGenerator.singPass(profile)
|
|
49
52
|
return { id, assertURL }
|
|
@@ -52,7 +55,10 @@ function config(app, { showLoginPage, serviceProvider }) {
|
|
|
52
55
|
res.send(response)
|
|
53
56
|
} else {
|
|
54
57
|
const profile = defaultProfile
|
|
55
|
-
const authCode = generateAuthCode(
|
|
58
|
+
const authCode = generateAuthCode(
|
|
59
|
+
{ profile, scopes, nonce },
|
|
60
|
+
{ isStateless },
|
|
61
|
+
)
|
|
56
62
|
const assertURL = buildAssertURL(redirectURI, authCode, state)
|
|
57
63
|
console.info(
|
|
58
64
|
`Redirecting login from ${req.query.client_id} to ${assertURL}`,
|
|
@@ -74,7 +80,9 @@ function config(app, { showLoginPage, serviceProvider }) {
|
|
|
74
80
|
)
|
|
75
81
|
|
|
76
82
|
try {
|
|
77
|
-
const { profile, scopes, nonce } = lookUpByAuthCode(authCode
|
|
83
|
+
const { profile, scopes, nonce } = lookUpByAuthCode(authCode, {
|
|
84
|
+
isStateless,
|
|
85
|
+
})
|
|
78
86
|
console.info(
|
|
79
87
|
`Profile ${JSON.stringify(profile)} with token scope ${scopes}`,
|
|
80
88
|
)
|
|
@@ -120,7 +128,9 @@ function config(app, { showLoginPage, serviceProvider }) {
|
|
|
120
128
|
req.headers.authorization || req.headers.Authorization
|
|
121
129
|
).replace('Bearer ', '')
|
|
122
130
|
// eslint-disable-next-line no-unused-vars
|
|
123
|
-
const { profile, scopes, unused } = lookUpByAuthCode(authCode
|
|
131
|
+
const { profile, scopes, unused } = lookUpByAuthCode(authCode, {
|
|
132
|
+
isStateless,
|
|
133
|
+
})
|
|
124
134
|
const uuid = profile.uuid
|
|
125
135
|
const nric = assertions.oidc.singPass.find((p) => p.uuid === uuid).nric
|
|
126
136
|
const persona = assertions.myinfo.v3.personas[nric]
|
|
@@ -258,6 +268,10 @@ const formatVehicles = (vehicles) => {
|
|
|
258
268
|
return vehicleObjects
|
|
259
269
|
}
|
|
260
270
|
|
|
271
|
+
const formatJsonStringify = (value) => {
|
|
272
|
+
return value == undefined ? 'NA' : JSON.stringify(value)
|
|
273
|
+
}
|
|
274
|
+
|
|
261
275
|
const defaultUndefinedToNA = (value) => {
|
|
262
276
|
return value || 'NA'
|
|
263
277
|
}
|
|
@@ -310,6 +324,8 @@ const sgIDScopeToMyInfoField = (persona, scope) => {
|
|
|
310
324
|
return defaultUndefinedToNA(persona.marital?.desc)
|
|
311
325
|
case 'myinfo.mobile_number_with_country_code':
|
|
312
326
|
return formatMobileNumberWithPrefix(persona.mobileno)
|
|
327
|
+
case 'pocdex.public_officer_details':
|
|
328
|
+
return formatJsonStringify(persona.publicofficerdetails)
|
|
313
329
|
default:
|
|
314
330
|
return 'NA'
|
|
315
331
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengovsg/mockpass",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.2",
|
|
4
4
|
"description": "A mock SingPass/CorpPass server for dev purposes",
|
|
5
5
|
"main": "app.js",
|
|
6
6
|
"bin": {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"cz": "git-cz",
|
|
13
13
|
"lint": "eslint lib",
|
|
14
14
|
"lint-fix": "eslint --fix lib",
|
|
15
|
-
"
|
|
15
|
+
"prepare": "node .husky/install.mjs",
|
|
16
16
|
"prepublishOnly": "pinst --disable",
|
|
17
17
|
"postpublish": "pinst --enable"
|
|
18
18
|
},
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"dotenv": "^16.0.0",
|
|
42
42
|
"expiry-map": "^2.0.0",
|
|
43
43
|
"express": "^4.16.3",
|
|
44
|
-
"jose": "^
|
|
44
|
+
"jose": "^5.2.3",
|
|
45
45
|
"jsonwebtoken": "^9.0.0",
|
|
46
46
|
"lodash": "^4.17.11",
|
|
47
47
|
"morgan": "^1.9.1",
|
|
@@ -50,16 +50,16 @@
|
|
|
50
50
|
"uuid": "^9.0.0"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
|
-
"@commitlint/cli": "^
|
|
54
|
-
"@commitlint/config-conventional": "^
|
|
55
|
-
"@commitlint/travis-cli": "^
|
|
53
|
+
"@commitlint/cli": "^19.1.0",
|
|
54
|
+
"@commitlint/config-conventional": "^19.0.3",
|
|
55
|
+
"@commitlint/travis-cli": "^19.0.3",
|
|
56
56
|
"commitizen": "^4.2.4",
|
|
57
57
|
"cz-conventional-changelog": "^3.2.0",
|
|
58
58
|
"eslint": "^8.0.0",
|
|
59
|
-
"eslint-config-prettier": "^
|
|
59
|
+
"eslint-config-prettier": "^9.1.0",
|
|
60
60
|
"eslint-plugin-prettier": "^4.0.0",
|
|
61
|
-
"husky": "^
|
|
62
|
-
"lint-staged": "^
|
|
61
|
+
"husky": "^9.0.11",
|
|
62
|
+
"lint-staged": "^15.2.2",
|
|
63
63
|
"nodemon": "^3.0.1",
|
|
64
64
|
"pinst": "^3.0.0",
|
|
65
65
|
"prettier": "^2.0.5"
|
package/static/myinfo/v3.json
CHANGED
|
@@ -1203,7 +1203,16 @@
|
|
|
1203
1203
|
"source": "1",
|
|
1204
1204
|
"classification": "C",
|
|
1205
1205
|
"desc": ""
|
|
1206
|
-
}
|
|
1206
|
+
},
|
|
1207
|
+
"publicofficerdetails": [
|
|
1208
|
+
{
|
|
1209
|
+
"work_email": "lim_yong_xiang@was.gov.sg",
|
|
1210
|
+
"agency_name": "Work Allocation Singapore",
|
|
1211
|
+
"department_name": "Allocation Central",
|
|
1212
|
+
"employment_type": "Fixed Term",
|
|
1213
|
+
"employment_title": "Senior Software Engineer - LLv1 (Individual Contributor) (WAS)"
|
|
1214
|
+
}
|
|
1215
|
+
]
|
|
1207
1216
|
},
|
|
1208
1217
|
"S9912370B": {
|
|
1209
1218
|
"edulevel": {
|
|
@@ -1517,7 +1526,7 @@
|
|
|
1517
1526
|
"code": "C",
|
|
1518
1527
|
"source": "1",
|
|
1519
1528
|
"classification": "C",
|
|
1520
|
-
"desc": "
|
|
1529
|
+
"desc": "CITIZEN"
|
|
1521
1530
|
},
|
|
1522
1531
|
"cpfbalances": {
|
|
1523
1532
|
"lastupdated": "2020-04-16",
|
|
@@ -2415,7 +2424,7 @@
|
|
|
2415
2424
|
"code": "C",
|
|
2416
2425
|
"source": "1",
|
|
2417
2426
|
"classification": "C",
|
|
2418
|
-
"desc": "
|
|
2427
|
+
"desc": "CITIZEN"
|
|
2419
2428
|
},
|
|
2420
2429
|
"cpfbalances": {
|
|
2421
2430
|
"lastupdated": "2020-04-16",
|
|
@@ -7065,7 +7074,7 @@
|
|
|
7065
7074
|
"code": "C",
|
|
7066
7075
|
"source": "1",
|
|
7067
7076
|
"classification": "C",
|
|
7068
|
-
"desc": "
|
|
7077
|
+
"desc": "CITIZEN"
|
|
7069
7078
|
},
|
|
7070
7079
|
"cpfbalances": {
|
|
7071
7080
|
"lastupdated": "2020-04-16",
|
|
@@ -8214,7 +8223,7 @@
|
|
|
8214
8223
|
"code": "C",
|
|
8215
8224
|
"source": "1",
|
|
8216
8225
|
"classification": "C",
|
|
8217
|
-
"desc": "
|
|
8226
|
+
"desc": "CITIZEN"
|
|
8218
8227
|
},
|
|
8219
8228
|
"cpfbalances": {
|
|
8220
8229
|
"lastupdated": "2020-04-16",
|
|
@@ -9302,7 +9311,7 @@
|
|
|
9302
9311
|
"code": "C",
|
|
9303
9312
|
"source": "1",
|
|
9304
9313
|
"classification": "C",
|
|
9305
|
-
"desc": "
|
|
9314
|
+
"desc": "CITIZEN"
|
|
9306
9315
|
},
|
|
9307
9316
|
"cpfbalances": {
|
|
9308
9317
|
"lastupdated": "2020-04-16",
|
|
@@ -10104,7 +10113,7 @@
|
|
|
10104
10113
|
"code": "C",
|
|
10105
10114
|
"source": "1",
|
|
10106
10115
|
"classification": "C",
|
|
10107
|
-
"desc": "
|
|
10116
|
+
"desc": "CITIZEN"
|
|
10108
10117
|
},
|
|
10109
10118
|
"cpfbalances": {
|
|
10110
10119
|
"lastupdated": "2020-02-04",
|
|
@@ -12107,7 +12116,7 @@
|
|
|
12107
12116
|
"code": "C",
|
|
12108
12117
|
"source": "1",
|
|
12109
12118
|
"classification": "C",
|
|
12110
|
-
"desc": "
|
|
12119
|
+
"desc": "CITIZEN"
|
|
12111
12120
|
},
|
|
12112
12121
|
"cpfbalances": {
|
|
12113
12122
|
"lastupdated": "2020-04-16",
|
|
@@ -13817,7 +13826,7 @@
|
|
|
13817
13826
|
"code": "C",
|
|
13818
13827
|
"source": "1",
|
|
13819
13828
|
"classification": "C",
|
|
13820
|
-
"desc": "
|
|
13829
|
+
"desc": "CITIZEN"
|
|
13821
13830
|
},
|
|
13822
13831
|
"cpfbalances": {
|
|
13823
13832
|
"oa": {
|
|
@@ -14473,7 +14482,7 @@
|
|
|
14473
14482
|
"code": "C",
|
|
14474
14483
|
"source": "1",
|
|
14475
14484
|
"classification": "C",
|
|
14476
|
-
"desc": "
|
|
14485
|
+
"desc": "CITIZEN"
|
|
14477
14486
|
},
|
|
14478
14487
|
"cpfbalances": {
|
|
14479
14488
|
"lastupdated": "2020-04-16",
|
|
@@ -15150,7 +15159,7 @@
|
|
|
15150
15159
|
"code": "C",
|
|
15151
15160
|
"source": "1",
|
|
15152
15161
|
"classification": "C",
|
|
15153
|
-
"desc": "
|
|
15162
|
+
"desc": "CITIZEN"
|
|
15154
15163
|
},
|
|
15155
15164
|
"cpfbalances": {
|
|
15156
15165
|
"lastupdated": "2020-04-16",
|
|
@@ -16153,7 +16162,7 @@
|
|
|
16153
16162
|
"code": "C",
|
|
16154
16163
|
"source": "1",
|
|
16155
16164
|
"classification": "C",
|
|
16156
|
-
"desc": "
|
|
16165
|
+
"desc": "CITIZEN"
|
|
16157
16166
|
},
|
|
16158
16167
|
"cpfbalances": {
|
|
16159
16168
|
"lastupdated": "2020-04-16",
|
|
@@ -19137,7 +19146,7 @@
|
|
|
19137
19146
|
"code": "C",
|
|
19138
19147
|
"source": "1",
|
|
19139
19148
|
"classification": "C",
|
|
19140
|
-
"desc": "
|
|
19149
|
+
"desc": "CITIZEN"
|
|
19141
19150
|
},
|
|
19142
19151
|
"cpfbalances": {
|
|
19143
19152
|
"lastupdated": "2020-04-16",
|
|
@@ -24343,7 +24352,7 @@
|
|
|
24343
24352
|
"code": "C",
|
|
24344
24353
|
"source": "1",
|
|
24345
24354
|
"classification": "C",
|
|
24346
|
-
"desc": "
|
|
24355
|
+
"desc": "CITIZEN"
|
|
24347
24356
|
},
|
|
24348
24357
|
"cpfbalances": {
|
|
24349
24358
|
"lastupdated": "2020-04-16",
|
|
@@ -26126,7 +26135,7 @@
|
|
|
26126
26135
|
"code": "C",
|
|
26127
26136
|
"source": "1",
|
|
26128
26137
|
"classification": "C",
|
|
26129
|
-
"desc": "
|
|
26138
|
+
"desc": "CITIZEN"
|
|
26130
26139
|
},
|
|
26131
26140
|
"cpfbalances": {
|
|
26132
26141
|
"oa": {
|
|
@@ -26919,7 +26928,7 @@
|
|
|
26919
26928
|
"code": "C",
|
|
26920
26929
|
"source": "1",
|
|
26921
26930
|
"classification": "C",
|
|
26922
|
-
"desc": "
|
|
26931
|
+
"desc": "CITIZEN"
|
|
26923
26932
|
},
|
|
26924
26933
|
"cpfbalances": {
|
|
26925
26934
|
"lastupdated": "2020-04-16",
|
|
@@ -27960,7 +27969,7 @@
|
|
|
27960
27969
|
"code": "C",
|
|
27961
27970
|
"source": "1",
|
|
27962
27971
|
"classification": "C",
|
|
27963
|
-
"desc": "
|
|
27972
|
+
"desc": "CITIZEN"
|
|
27964
27973
|
},
|
|
27965
27974
|
"cpfbalances": {
|
|
27966
27975
|
"oa": {
|