@things-factory/auth-base 10.1.31 → 10.1.33
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/dist-server/controllers/invitation.d.ts +47 -10
- package/dist-server/controllers/invitation.js +159 -113
- package/dist-server/controllers/invitation.js.map +1 -1
- package/dist-server/controllers/profile.d.ts +1 -0
- package/dist-server/router/auth-public-process-router.js +36 -13
- package/dist-server/router/auth-public-process-router.js.map +1 -1
- package/dist-server/router/oauth2/oauth2-router.js +7 -2
- package/dist-server/router/oauth2/oauth2-router.js.map +1 -1
- package/dist-server/router/oauth2/oauth2-server.js +4 -4
- package/dist-server/router/oauth2/oauth2-server.js.map +1 -1
- package/dist-server/service/app-binding/app-binding-mutation.d.ts +27 -0
- package/dist-server/service/app-binding/app-binding-mutation.js +39 -6
- package/dist-server/service/app-binding/app-binding-mutation.js.map +1 -1
- package/dist-server/service/app-binding/app-binding-query.js +14 -7
- package/dist-server/service/app-binding/app-binding-query.js.map +1 -1
- package/dist-server/service/app-binding/app-binding.d.ts +9 -0
- package/dist-server/service/app-binding/app-binding.js +10 -1
- package/dist-server/service/app-binding/app-binding.js.map +1 -1
- package/dist-server/service/appliance/appliance-mutation.js +34 -1
- package/dist-server/service/appliance/appliance-mutation.js.map +1 -1
- package/dist-server/service/appliance/appliance-query.d.ts +2 -0
- package/dist-server/service/appliance/appliance-query.js +48 -0
- package/dist-server/service/appliance/appliance-query.js.map +1 -1
- package/dist-server/service/appliance/appliance.d.ts +1 -0
- package/dist-server/service/appliance/appliance.js +33 -3
- package/dist-server/service/appliance/appliance.js.map +1 -1
- package/dist-server/service/application/application-mutation.js +51 -6
- package/dist-server/service/application/application-mutation.js.map +1 -1
- package/dist-server/service/application/application.d.ts +9 -3
- package/dist-server/service/application/application.js +24 -8
- package/dist-server/service/application/application.js.map +1 -1
- package/dist-server/service/auth-provider/auth-provider-mutation.js +5 -0
- package/dist-server/service/auth-provider/auth-provider-mutation.js.map +1 -1
- package/dist-server/service/domain-generator/domain-generator-mutation.js +3 -0
- package/dist-server/service/domain-generator/domain-generator-mutation.js.map +1 -1
- package/dist-server/service/invitation/invitation-mutation.d.ts +17 -15
- package/dist-server/service/invitation/invitation-mutation.js +83 -56
- package/dist-server/service/invitation/invitation-mutation.js.map +1 -1
- package/dist-server/service/invitation/invitation-query.d.ts +20 -5
- package/dist-server/service/invitation/invitation-query.js +60 -19
- package/dist-server/service/invitation/invitation-query.js.map +1 -1
- package/dist-server/service/invitation/invitation.d.ts +14 -2
- package/dist-server/service/invitation/invitation.js +77 -8
- package/dist-server/service/invitation/invitation.js.map +1 -1
- package/dist-server/service/login-history/login-history-query.js +3 -0
- package/dist-server/service/login-history/login-history-query.js.map +1 -1
- package/dist-server/service/role/role-mutation.js +14 -15
- package/dist-server/service/role/role-mutation.js.map +1 -1
- package/dist-server/service/role/role-query.js +26 -4
- package/dist-server/service/role/role-query.js.map +1 -1
- package/dist-server/service/role/role-types.d.ts +2 -0
- package/dist-server/service/role/role-types.js +8 -0
- package/dist-server/service/role/role-types.js.map +1 -1
- package/dist-server/service/role-template/role-template-mutation.d.ts +17 -1
- package/dist-server/service/role-template/role-template-mutation.js +14 -3
- package/dist-server/service/role-template/role-template-mutation.js.map +1 -1
- package/dist-server/service/user/user-mutation.js +1 -0
- package/dist-server/service/user/user-mutation.js.map +1 -1
- package/dist-server/service/user/user.d.ts +1 -0
- package/dist-server/service/user/user.js +19 -4
- package/dist-server/service/user/user.js.map +1 -1
- package/dist-server/templates/invitation-email.d.ts +2 -1
- package/dist-server/templates/invitation-email.js +16 -4
- package/dist-server/templates/invitation-email.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/dist-server/utils/credential-at-rest.d.ts +28 -0
- package/dist-server/utils/credential-at-rest.js +36 -0
- package/dist-server/utils/credential-at-rest.js.map +1 -0
- package/dist-server/utils/credential-features.d.ts +35 -0
- package/dist-server/utils/credential-features.js +45 -0
- package/dist-server/utils/credential-features.js.map +1 -0
- package/dist-server/utils/credential-serial-rule.d.ts +41 -0
- package/dist-server/utils/credential-serial-rule.js +43 -0
- package/dist-server/utils/credential-serial-rule.js.map +1 -0
- package/dist-server/utils/invitation-state.d.ts +52 -0
- package/dist-server/utils/invitation-state.js +76 -0
- package/dist-server/utils/invitation-state.js.map +1 -0
- package/dist-server/utils/refuse-role-name.d.ts +42 -0
- package/dist-server/utils/refuse-role-name.js +76 -0
- package/dist-server/utils/refuse-role-name.js.map +1 -0
- package/dist-server/utils/role-name-standing.d.ts +58 -0
- package/dist-server/utils/role-name-standing.js +72 -0
- package/dist-server/utils/role-name-standing.js.map +1 -0
- package/package.json +4 -4
- package/tests/app-binding-delete-db.test.ts +188 -0
- package/tests/appliance-credential-state-db.test.ts +207 -0
- package/tests/appliance-delete-db.test.ts +119 -0
- package/tests/appliance-revoke-db.test.ts +73 -28
- package/tests/appliance-schema.test.ts +129 -0
- package/tests/application-delete-db.test.ts +190 -0
- package/tests/application-token-payload-db.test.ts +185 -0
- package/tests/checkin-privilege-seam-db.test.ts +158 -0
- package/tests/credential-at-rest-db.test.ts +246 -0
- package/tests/credential-features-off-db.test.ts +189 -0
- package/tests/credential-serial-rule.test.ts +84 -0
- package/tests/invitation-db.test.ts +416 -0
- package/tests/invitation-state.test.ts +92 -0
- package/tests/open-doors.test.ts +132 -0
- package/tests/role-inheritance-db.test.ts +286 -0
- package/tests/role-mutation-db.test.ts +21 -7
- package/tests/role-name-lookup-sentinel.test.ts +79 -0
- package/tests/role-name-standing.test.ts +83 -0
- package/tests/token-issuance.test.ts +7 -3
- package/translations/en.json +2 -0
- package/translations/ja.json +2 -0
- package/translations/ko.json +2 -0
- package/translations/ms.json +2 -0
- package/translations/zh.json +2 -0
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
* value that is stored and read back, not the dialect's own enum behaviour.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import { config } from '@things-factory/env'
|
|
21
|
+
|
|
20
22
|
import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
|
|
21
23
|
import { loadCompiled } from './compiled'
|
|
22
24
|
|
|
@@ -32,11 +34,14 @@ let domain: any
|
|
|
32
34
|
let actor: any
|
|
33
35
|
|
|
34
36
|
function context(d: any = domain) {
|
|
35
|
-
return {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
return {
|
|
38
|
+
state: { domain: d, user: actor, tx: undefined },
|
|
39
|
+
throw: (code: number, message: string) => {
|
|
40
|
+
const error: any = new Error(message)
|
|
41
|
+
error.status = code
|
|
42
|
+
throw error
|
|
43
|
+
}
|
|
44
|
+
} as any
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
async function shadowUserOf(applianceId: string) {
|
|
@@ -53,11 +58,25 @@ async function makeAppliance(name: string) {
|
|
|
53
58
|
return await mutation.generateApplianceSecret(appliance.id, context())
|
|
54
59
|
}
|
|
55
60
|
|
|
61
|
+
/*
|
|
62
|
+
* Revocation is off unless the installation asks for it (ADR-0085 decision 2), so these cases
|
|
63
|
+
* turn it on. `credential-features.ts` reads the config on each call, which is what lets a test
|
|
64
|
+
* stand in for a configured installation.
|
|
65
|
+
*
|
|
66
|
+
* `credential-features-off.test.ts` holds the other side — what an installation that configures
|
|
67
|
+
* nothing sees.
|
|
68
|
+
*/
|
|
69
|
+
const configGet = config.get
|
|
70
|
+
|
|
56
71
|
beforeAll(async () => {
|
|
72
|
+
config.get = ((key: string, fallback?: any) =>
|
|
73
|
+
key === 'credential/revocable' ? true : configGet(key, fallback)) as typeof config.get
|
|
74
|
+
|
|
57
75
|
await openAuthDatabase()
|
|
58
76
|
})
|
|
59
77
|
|
|
60
78
|
afterAll(async () => {
|
|
79
|
+
config.get = configGet
|
|
61
80
|
await closeAuthDatabase()
|
|
62
81
|
})
|
|
63
82
|
|
|
@@ -107,6 +126,31 @@ describe('revoking an appliance credential', () => {
|
|
|
107
126
|
expect(appuser!.status).toEqual(UserStatus.INACTIVE)
|
|
108
127
|
})
|
|
109
128
|
|
|
129
|
+
it('moves the credential serial, which is what a status alone cannot do', async () => {
|
|
130
|
+
/*
|
|
131
|
+
* ⚠ The serial is the half of revocation that cannot be undone by putting the status back.
|
|
132
|
+
* Its effect shows up in `checkAuth` (ADR-0085 decision 4), so until that lands nothing else
|
|
133
|
+
* here would notice if the bump went missing — this case watches the column directly.
|
|
134
|
+
*/
|
|
135
|
+
const issued = await makeAppliance('gate-s')
|
|
136
|
+
const before = (await shadowUserOf(issued.id))!.credentialSerial
|
|
137
|
+
|
|
138
|
+
expect(before).toEqual(1)
|
|
139
|
+
|
|
140
|
+
await mutation.revokeApplianceCredential(issued.id, context())
|
|
141
|
+
|
|
142
|
+
expect((await shadowUserOf(issued.id))!.credentialSerial).toEqual(before + 1)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('moves it again on the next issue, so no generation is reused', async () => {
|
|
146
|
+
const issued = await makeAppliance('gate-t')
|
|
147
|
+
|
|
148
|
+
await mutation.revokeApplianceCredential(issued.id, context())
|
|
149
|
+
await mutation.generateApplianceSecret(issued.id, context())
|
|
150
|
+
|
|
151
|
+
expect((await shadowUserOf(issued.id))!.credentialSerial).toEqual(3)
|
|
152
|
+
})
|
|
153
|
+
|
|
110
154
|
it('keeps the shadow user rather than deleting it', async () => {
|
|
111
155
|
/*
|
|
112
156
|
* `deleteAppliance` deletes it, and that is right when the appliance goes too. Here the
|
|
@@ -163,27 +207,26 @@ describe('revoking an appliance credential', () => {
|
|
|
163
207
|
expect(appuser!.password).toEqual(reissued.accessToken)
|
|
164
208
|
})
|
|
165
209
|
|
|
166
|
-
it('
|
|
210
|
+
it('does not hand back the token it just revoked, even re-issued in the same second', async () => {
|
|
167
211
|
/*
|
|
168
|
-
* This
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
212
|
+
* This used to pin a defect. The payload carried `{ id, userType, appliance.id, status,
|
|
213
|
+
* domain.subdomain }` and nothing else, and `iat` counts whole seconds, so two signings
|
|
214
|
+
* inside one second produced a byte-identical string — revoking and immediately re-issuing,
|
|
215
|
+
* which is the obvious way to rotate a suspect device, handed back the credential just
|
|
216
|
+
* revoked.
|
|
172
217
|
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
* Revoking and immediately re-issuing — the obvious way to rotate a suspect device — is
|
|
176
|
-
* exactly the case where this bites.
|
|
177
|
-
*
|
|
178
|
-
* Closing it needs a per-issue claim in the payload (jti) that also gives the token
|
|
179
|
-
* something to be refused by. Until then this test says out loud what the product does.
|
|
218
|
+
* The credential serial closes it: it moves on every issue and every revoke, and it is in
|
|
219
|
+
* the payload, so no two issues can produce the same token.
|
|
180
220
|
*/
|
|
181
221
|
const issued = await makeAppliance('gate-g')
|
|
182
222
|
|
|
183
223
|
await mutation.revokeApplianceCredential(issued.id, context())
|
|
184
224
|
const reissued = await mutation.generateApplianceSecret(issued.id, context())
|
|
185
225
|
|
|
186
|
-
expect(reissued.accessToken).toEqual(issued.accessToken)
|
|
226
|
+
expect(reissued.accessToken).not.toEqual(issued.accessToken)
|
|
227
|
+
|
|
228
|
+
/* And the reason is the serial, not the clock — the two tokens name different generations. */
|
|
229
|
+
expect(jwt.decode(reissued.accessToken).cs).toBeGreaterThan(jwt.decode(issued.accessToken).cs)
|
|
187
230
|
})
|
|
188
231
|
|
|
189
232
|
it('refuses an appliance that belongs to another domain', async () => {
|
|
@@ -222,14 +265,15 @@ describe('what an already-issued token can still do', () => {
|
|
|
222
265
|
await expect(User.checkAuth(decode(issued.accessToken))).rejects.toThrow()
|
|
223
266
|
})
|
|
224
267
|
|
|
225
|
-
it('
|
|
268
|
+
it('stays refused when the status goes back, because the serial moved', async () => {
|
|
226
269
|
/*
|
|
227
|
-
* This
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
270
|
+
* This used to pin a hazard. Revocation lived entirely in the shadow user's status, so
|
|
271
|
+
* putting the status back — by hand, by a fixture, by some future screen — let every token
|
|
272
|
+
* ever issued to that appliance work again. Clearing the stored copy did not help: the JWT
|
|
273
|
+
* verifies on its signature and never against that column.
|
|
231
274
|
*
|
|
232
|
-
*
|
|
275
|
+
* The serial closes it. It moved on revoke and does not move back, so the old token names a
|
|
276
|
+
* generation this subject has left. Access can only come from issuing a new one.
|
|
233
277
|
*/
|
|
234
278
|
const issued = await makeAppliance('gate-i')
|
|
235
279
|
const oldToken = issued.accessToken
|
|
@@ -240,10 +284,11 @@ describe('what an already-issued token can still do', () => {
|
|
|
240
284
|
const appuser = await shadowUserOf(issued.id)
|
|
241
285
|
await getRepository(User).save({ ...appuser, status: UserStatus.ACTIVATED })
|
|
242
286
|
|
|
243
|
-
|
|
287
|
+
await expect(User.checkAuth(decode(oldToken))).rejects.toThrow()
|
|
288
|
+
|
|
289
|
+
/* And a freshly issued one does work — the subject is usable again, its old tokens are not. */
|
|
290
|
+
const reissued = await mutation.generateApplianceSecret(issued.id, context())
|
|
244
291
|
|
|
245
|
-
expect(
|
|
246
|
-
expect(subject.id).toEqual(appuser!.id)
|
|
247
|
-
expect(subject.password).toBeNull()
|
|
292
|
+
await expect(User.checkAuth(decode(reissued.accessToken))).resolves.toBeTruthy()
|
|
248
293
|
})
|
|
249
294
|
})
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The appliance resolvers make a schema, and the two new fields carry the gate they meant to.
|
|
3
|
+
*
|
|
4
|
+
* ── Why a schema test and not another unit test ─────────────────────────────
|
|
5
|
+
* `credentialRevoked` is a field with no column behind it and `applianceCredentialRevocable` is
|
|
6
|
+
* a Query on a resolver whose other methods all return objects. Both are the shapes that build
|
|
7
|
+
* fine in TypeScript and fail when type-graphql reads the decorators — an inferred type it
|
|
8
|
+
* cannot resolve takes the whole schema down, and a schema that does not build is a server that
|
|
9
|
+
* comes up without its doors (`reference_resolver_load_failure_leaves_server_half_up`).
|
|
10
|
+
*
|
|
11
|
+
* ── What the gate assertion is for ──────────────────────────────────────────
|
|
12
|
+
* `applianceCredentialRevocable` exists so a screen can ask "would pressing revoke work for
|
|
13
|
+
* me". That answer is only worth anything if the query is refused in the same cases the
|
|
14
|
+
* mutation is, so the two declarations have to stay identical. Nothing else would notice them
|
|
15
|
+
* drifting apart: the query would simply start answering people who cannot act on it, and the
|
|
16
|
+
* button would appear and then refuse.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { buildSchema } from 'type-graphql'
|
|
20
|
+
import { GraphQLSchema } from 'graphql'
|
|
21
|
+
|
|
22
|
+
import { loadCompiled } from './compiled'
|
|
23
|
+
|
|
24
|
+
const { ApplianceQuery } = loadCompiled('service/appliance/appliance-query')
|
|
25
|
+
const { ApplianceMutation } = loadCompiled('service/appliance/appliance-mutation')
|
|
26
|
+
const { ApplicationQuery } = loadCompiled('service/application/application-query')
|
|
27
|
+
const { ApplicationMutation } = loadCompiled('service/application/application-mutation')
|
|
28
|
+
const { AppBindingQuery } = loadCompiled('service/app-binding/app-binding-query')
|
|
29
|
+
const { AppBindingMutation } = loadCompiled('service/app-binding/app-binding-mutation')
|
|
30
|
+
const { privilegeDirectiveResolver, privilegeOfField } = loadCompiled('service/privilege/privilege-directive')
|
|
31
|
+
|
|
32
|
+
let schema: GraphQLSchema
|
|
33
|
+
|
|
34
|
+
beforeAll(async () => {
|
|
35
|
+
schema = await buildSchema({
|
|
36
|
+
resolvers: [
|
|
37
|
+
ApplianceQuery,
|
|
38
|
+
ApplianceMutation,
|
|
39
|
+
ApplicationQuery,
|
|
40
|
+
ApplicationMutation,
|
|
41
|
+
AppBindingQuery,
|
|
42
|
+
AppBindingMutation
|
|
43
|
+
],
|
|
44
|
+
validate: false
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
/* The map of what each field asks for is filled in while the directive walks the schema. */
|
|
48
|
+
privilegeDirectiveResolver(schema)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('the schema builds', () => {
|
|
52
|
+
it('has the query the screen asks before showing a revoke button', () => {
|
|
53
|
+
const field = schema.getQueryType()?.getFields()['applianceCredentialRevocable']
|
|
54
|
+
|
|
55
|
+
expect(field).toBeDefined()
|
|
56
|
+
|
|
57
|
+
/* Non-null: it always has an answer, and "no answer" would read as "no button". */
|
|
58
|
+
expect(String(field!.type)).toEqual('Boolean!')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('has the field that separates revoked from never issued', () => {
|
|
62
|
+
const field = schema.getType('Appliance')?.['getFields']()['credentialRevoked']
|
|
63
|
+
|
|
64
|
+
expect(field).toBeDefined()
|
|
65
|
+
|
|
66
|
+
/*
|
|
67
|
+
* Nullable, unlike the query above. This field rides on the Appliance type, which is also
|
|
68
|
+
* what the mutations hand back, and a caller that does not ask for it should get a record
|
|
69
|
+
* without it rather than one asserting the credential is live. The screen reads absent as
|
|
70
|
+
* not revoked — `client/utils/credential-panel-state.ts` in auth-ui pins that.
|
|
71
|
+
*/
|
|
72
|
+
expect(String(field.type)).toEqual('Boolean')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('still has the door those two describe', () => {
|
|
76
|
+
expect(schema.getMutationType()?.getFields()['revokeApplianceCredential']).toBeDefined()
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('the question is gated like the act it describes', () => {
|
|
81
|
+
it('asks for exactly what revoking asks for', () => {
|
|
82
|
+
/*
|
|
83
|
+
* ⚠ The pair that has to stay together. If the query opened up, it would answer yes to a
|
|
84
|
+
* reader the mutation refuses, and the screen would put a button in front of somebody who
|
|
85
|
+
* cannot press it.
|
|
86
|
+
*/
|
|
87
|
+
const asking = privilegeOfField('Query', 'applianceCredentialRevocable')
|
|
88
|
+
const acting = privilegeOfField('Mutation', 'revokeApplianceCredential')
|
|
89
|
+
|
|
90
|
+
expect(asking.known).toBe(true)
|
|
91
|
+
expect(acting.known).toBe(true)
|
|
92
|
+
expect(asking['gate']).toEqual(acting['gate'])
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('is not an open field', () => {
|
|
96
|
+
/* `null` is how the map records a root field that declares nothing. */
|
|
97
|
+
expect(privilegeOfField('Query', 'applianceCredentialRevocable')['gate']).not.toBeNull()
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('no door on a machine credential is left open', () => {
|
|
102
|
+
/*
|
|
103
|
+
* ⚠ The sweep this file exists for as much as for the two fields above. `deleteAppBinding`
|
|
104
|
+
* carried no privilege at all, and nothing noticed: an undeclared root field is recorded as
|
|
105
|
+
* open, so it reads exactly like a field somebody decided to open. The two app binding reads
|
|
106
|
+
* were the same.
|
|
107
|
+
*
|
|
108
|
+
* These three resources are one family — an appliance, an application, and a client bound to
|
|
109
|
+
* one — and every way in or out of them is a machine credential. So the assertion is on the
|
|
110
|
+
* whole family rather than on the doors that happen to be remembered.
|
|
111
|
+
*/
|
|
112
|
+
|
|
113
|
+
/** Every root field these six resolvers put in the schema. */
|
|
114
|
+
function rootFields(typeName: string): string[] {
|
|
115
|
+
const type = typeName === 'Query' ? schema.getQueryType() : schema.getMutationType()
|
|
116
|
+
|
|
117
|
+
return Object.keys(type?.getFields() || {})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
it.each(['Query', 'Mutation'])('%s', typeName => {
|
|
121
|
+
const open = rootFields(typeName).filter(field => {
|
|
122
|
+
const declared = privilegeOfField(typeName, field)
|
|
123
|
+
|
|
124
|
+
return declared.known && declared['gate'] === null
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
expect(open).toEqual([])
|
|
128
|
+
})
|
|
129
|
+
})
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deleting an application has to stop the clients bound to it.
|
|
3
|
+
*
|
|
4
|
+
* ── The asymmetry this came from ────────────────────────────────────────────
|
|
5
|
+
* An appliance and an application both have a shadow `User` standing in for the machine, and
|
|
6
|
+
* that user is the thing a token names. `deleteAppliance` deletes it along with the appliance.
|
|
7
|
+
* `deleteApplication` deleted only the application row, and the shadow users stayed behind:
|
|
8
|
+
*
|
|
9
|
+
* ACTIVATED, so `User.checkAuth` passes every token naming them — it looks the user up by the
|
|
10
|
+
* id in the token and never asks whether the application still exists
|
|
11
|
+
*
|
|
12
|
+
* holding a refresh token in `password`, which is what `/oauth2/refresh-token` finds a binding
|
|
13
|
+
* by, so each refresh mints another year of them
|
|
14
|
+
*
|
|
15
|
+
* So the screen said the application was gone and its clients kept working, with no way left on
|
|
16
|
+
* any screen to reach them — the list they would have appeared in is keyed on an application
|
|
17
|
+
* that no longer exists.
|
|
18
|
+
*
|
|
19
|
+
* ⚠ sqlite (`tests/db.ts`).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
|
|
23
|
+
import { loadCompiled } from './compiled'
|
|
24
|
+
|
|
25
|
+
const { Application } = loadCompiled('service/application/application')
|
|
26
|
+
const { User, UserStatus } = loadCompiled('service/user/user')
|
|
27
|
+
const { ApplicationMutation } = loadCompiled('service/application/application-mutation')
|
|
28
|
+
|
|
29
|
+
const mutation = new ApplicationMutation()
|
|
30
|
+
|
|
31
|
+
let domain: any
|
|
32
|
+
let other: any
|
|
33
|
+
let actor: any
|
|
34
|
+
|
|
35
|
+
function context(d: any = domain) {
|
|
36
|
+
return {
|
|
37
|
+
state: { domain: d, user: actor, tx: undefined },
|
|
38
|
+
throw: (code: number, message: string) => {
|
|
39
|
+
const error: any = new Error(message)
|
|
40
|
+
error.status = code
|
|
41
|
+
throw error
|
|
42
|
+
}
|
|
43
|
+
} as any
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function makeApplication(name: string, d: any = domain) {
|
|
47
|
+
return await mutation.createApplication(
|
|
48
|
+
{
|
|
49
|
+
name,
|
|
50
|
+
email: `${name}@acme.z`,
|
|
51
|
+
url: `https://${name}.example.com`,
|
|
52
|
+
redirectUrl: `https://${name}.example.com/callback`,
|
|
53
|
+
type: 'OTHERS'
|
|
54
|
+
},
|
|
55
|
+
context(d)
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A binding, as the OAuth2 exchange leaves one: a shadow user holding a refresh token. */
|
|
60
|
+
async function bind(application: any, label: string, d: any = domain) {
|
|
61
|
+
const appuser = await getRepository(User).save({
|
|
62
|
+
username: `${label}@acme`,
|
|
63
|
+
email: `${label}@acme`,
|
|
64
|
+
name: label,
|
|
65
|
+
userType: 'application',
|
|
66
|
+
reference: application.id,
|
|
67
|
+
status: UserStatus.ACTIVATED,
|
|
68
|
+
domains: [d]
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const refreshToken = Application.generateRefreshToken(d, appuser, application, 'admin')
|
|
72
|
+
|
|
73
|
+
return await getRepository(User).save({ ...appuser, password: refreshToken })
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function bindingsOf(applicationId: string) {
|
|
77
|
+
return await getRepository(User).findBy({ reference: applicationId, userType: 'application' })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
beforeAll(async () => {
|
|
81
|
+
await openAuthDatabase()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
afterAll(async () => {
|
|
85
|
+
await closeAuthDatabase()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
beforeEach(async () => {
|
|
89
|
+
await resetAuthDatabase()
|
|
90
|
+
|
|
91
|
+
domain = await getRepository(Domain).save({ name: 'acme', subdomain: 'acme' })
|
|
92
|
+
other = await getRepository(Domain).save({ name: 'beta', subdomain: 'beta' })
|
|
93
|
+
actor = await getRepository(User).save({
|
|
94
|
+
username: 'operator@acme.z',
|
|
95
|
+
email: 'operator@acme.z',
|
|
96
|
+
name: 'operator',
|
|
97
|
+
status: UserStatus.ACTIVATED,
|
|
98
|
+
domains: [domain]
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('deleting an application', () => {
|
|
103
|
+
it('takes its bindings with it', async () => {
|
|
104
|
+
/*
|
|
105
|
+
* ⚠ The case. A binding left behind is a live credential with no screen to reach it from —
|
|
106
|
+
* the list it would appear in is keyed on an application that is gone.
|
|
107
|
+
*/
|
|
108
|
+
const application = await makeApplication('billing')
|
|
109
|
+
await bind(application, 'binding-a')
|
|
110
|
+
await bind(application, 'binding-b')
|
|
111
|
+
|
|
112
|
+
await mutation.deleteApplication(application.id, context())
|
|
113
|
+
|
|
114
|
+
expect(await bindingsOf(application.id)).toHaveLength(0)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('leaves no token that checkAuth would still accept', async () => {
|
|
118
|
+
/*
|
|
119
|
+
* ⚠ Why the row mattering is not enough to say. `checkAuth` looks the subject up by the id
|
|
120
|
+
* baked into the token and never asks whether the application exists, so a surviving user
|
|
121
|
+
* authenticates whatever happened to the application row.
|
|
122
|
+
*/
|
|
123
|
+
const application = await makeApplication('billing')
|
|
124
|
+
const binding = await bind(application, 'binding-a')
|
|
125
|
+
|
|
126
|
+
await mutation.deleteApplication(application.id, context())
|
|
127
|
+
|
|
128
|
+
await expect(
|
|
129
|
+
User.checkAuth({ id: binding.id, userType: 'application', domain: { subdomain: 'acme' } })
|
|
130
|
+
).rejects.toThrow()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('deletes the application row itself', async () => {
|
|
134
|
+
const application = await makeApplication('billing')
|
|
135
|
+
|
|
136
|
+
await mutation.deleteApplication(application.id, context())
|
|
137
|
+
|
|
138
|
+
expect(await getRepository(Application).findOneBy({ id: application.id })).toBeNull()
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('leaves another application and its bindings alone', async () => {
|
|
142
|
+
const going = await makeApplication('billing')
|
|
143
|
+
const staying = await makeApplication('shipping')
|
|
144
|
+
|
|
145
|
+
await bind(going, 'binding-a')
|
|
146
|
+
const kept = await bind(staying, 'binding-b')
|
|
147
|
+
|
|
148
|
+
await mutation.deleteApplication(going.id, context())
|
|
149
|
+
|
|
150
|
+
expect(await bindingsOf(staying.id)).toHaveLength(1)
|
|
151
|
+
expect(await getRepository(User).findOneBy({ id: kept.id })).toMatchObject({ status: UserStatus.ACTIVATED })
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('does not reach into another tenant', async () => {
|
|
155
|
+
/*
|
|
156
|
+
* The delete is scoped by domain, so an id from elsewhere matches nothing. The bindings must
|
|
157
|
+
* not be cleared on the way to finding that out.
|
|
158
|
+
*/
|
|
159
|
+
const theirs = await makeApplication('billing', other)
|
|
160
|
+
await bind(theirs, 'binding-theirs', other)
|
|
161
|
+
|
|
162
|
+
await mutation.deleteApplication(theirs.id, context(domain))
|
|
163
|
+
|
|
164
|
+
expect(await getRepository(Application).findOneBy({ id: theirs.id })).not.toBeNull()
|
|
165
|
+
expect(await bindingsOf(theirs.id)).toHaveLength(1)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('does not touch an appliance that happens to carry the same reference', async () => {
|
|
169
|
+
/*
|
|
170
|
+
* `reference` is a plain column shared by both kinds of shadow user, so the delete has to
|
|
171
|
+
* name `userType` as well. Two ids never collide in practice; the assertion is about the
|
|
172
|
+
* filter, not about the odds.
|
|
173
|
+
*/
|
|
174
|
+
const application = await makeApplication('billing')
|
|
175
|
+
|
|
176
|
+
const applianceUser = await getRepository(User).save({
|
|
177
|
+
username: 'device@acme',
|
|
178
|
+
email: 'device@acme',
|
|
179
|
+
name: 'device',
|
|
180
|
+
userType: 'appliance',
|
|
181
|
+
reference: application.id,
|
|
182
|
+
status: UserStatus.ACTIVATED,
|
|
183
|
+
domains: [domain]
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
await mutation.deleteApplication(application.id, context())
|
|
187
|
+
|
|
188
|
+
expect(await getRepository(User).findOneBy({ id: applianceUser.id })).not.toBeNull()
|
|
189
|
+
})
|
|
190
|
+
})
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an application's tokens carry in their payload.
|
|
3
|
+
*
|
|
4
|
+
* ── Why this is worth a file of its own ─────────────────────────────────────
|
|
5
|
+
* An application has two 32-character hex strings and they are opposite kinds of thing:
|
|
6
|
+
*
|
|
7
|
+
* appKey the client id. Public. It is what a token says it was issued to, and what the
|
|
8
|
+
* OAuth2 refresh exchange looks the application up by.
|
|
9
|
+
* appSecret the client secret. It is what `/oauth2/access-token` compares against to decide
|
|
10
|
+
* the caller is that client.
|
|
11
|
+
*
|
|
12
|
+
* `Application.sign` takes the first in a positional slot, and a JWT payload is not encrypted —
|
|
13
|
+
* anyone holding the token reads it. So putting the second one in that slot hands the client
|
|
14
|
+
* secret to whoever holds the token, and nothing about the call site looks wrong at a glance:
|
|
15
|
+
* both values are strings on the same object.
|
|
16
|
+
*
|
|
17
|
+
* That is exactly what `renewApplicationAccessToken` did. These cases pin the axis the fix
|
|
18
|
+
* splits — what the payload names — rather than the shape of the call.
|
|
19
|
+
*
|
|
20
|
+
* ── What these do not cover ─────────────────────────────────────────────────
|
|
21
|
+
* The tokens issued before the fix. They carry the secret and they keep carrying it, and the
|
|
22
|
+
* decision is to leave them (2026-09-20 사용자 결정: 이미 발행된 토큰은 무시한다). So there is
|
|
23
|
+
* no audit here and no rule for finding them — only that nothing new goes out that way.
|
|
24
|
+
*
|
|
25
|
+
* ⚠ sqlite (`tests/db.ts`).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
|
|
29
|
+
import { loadCompiled } from './compiled'
|
|
30
|
+
|
|
31
|
+
const jwt = require('jsonwebtoken')
|
|
32
|
+
|
|
33
|
+
const { Application } = loadCompiled('service/application/application')
|
|
34
|
+
const { User, UserStatus } = loadCompiled('service/user/user')
|
|
35
|
+
const { ApplicationMutation } = loadCompiled('service/application/application-mutation')
|
|
36
|
+
|
|
37
|
+
const mutation = new ApplicationMutation()
|
|
38
|
+
|
|
39
|
+
let domain: any
|
|
40
|
+
let actor: any
|
|
41
|
+
|
|
42
|
+
function context(d: any = domain) {
|
|
43
|
+
return {
|
|
44
|
+
state: { domain: d, user: actor, tx: undefined },
|
|
45
|
+
throw: (code: number, message: string) => {
|
|
46
|
+
const error: any = new Error(message)
|
|
47
|
+
error.status = code
|
|
48
|
+
throw error
|
|
49
|
+
}
|
|
50
|
+
} as any
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** An application plus the shadow user a binding is, which the OAuth2 exchange creates. */
|
|
54
|
+
async function bindApplication(name: string) {
|
|
55
|
+
const application = await mutation.createApplication(
|
|
56
|
+
{
|
|
57
|
+
name,
|
|
58
|
+
email: `${name}@acme.z`,
|
|
59
|
+
url: `https://${name}.example.com`,
|
|
60
|
+
redirectUrl: `https://${name}.example.com/callback`,
|
|
61
|
+
type: 'OTHERS'
|
|
62
|
+
},
|
|
63
|
+
context()
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
const appuser = await getRepository(User).save({
|
|
67
|
+
username: `${application.appKey}@acme`,
|
|
68
|
+
email: `${application.appKey}@acme`,
|
|
69
|
+
name,
|
|
70
|
+
userType: 'application',
|
|
71
|
+
reference: application.id,
|
|
72
|
+
status: UserStatus.ACTIVATED,
|
|
73
|
+
domains: [domain]
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
return { application, appuser }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
beforeAll(async () => {
|
|
80
|
+
await openAuthDatabase()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
afterAll(async () => {
|
|
84
|
+
await closeAuthDatabase()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
beforeEach(async () => {
|
|
88
|
+
await resetAuthDatabase()
|
|
89
|
+
|
|
90
|
+
domain = await getRepository(Domain).save({ name: 'acme', subdomain: 'acme' })
|
|
91
|
+
actor = await getRepository(User).save({
|
|
92
|
+
username: 'operator@acme.z',
|
|
93
|
+
email: 'operator@acme.z',
|
|
94
|
+
name: 'operator',
|
|
95
|
+
status: UserStatus.ACTIVATED,
|
|
96
|
+
domains: [domain]
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('the two strings are not interchangeable', () => {
|
|
101
|
+
it('gives an application a key and a secret that differ', async () => {
|
|
102
|
+
/* If these were ever the same value the rest of this file would pass for the wrong reason. */
|
|
103
|
+
const { application } = await bindApplication('billing')
|
|
104
|
+
|
|
105
|
+
expect(application.appKey).toBeTruthy()
|
|
106
|
+
expect(application.appSecret).toBeTruthy()
|
|
107
|
+
expect(application.appKey).not.toEqual(application.appSecret)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('renewing an application access token', () => {
|
|
112
|
+
it('names the application by its key', async () => {
|
|
113
|
+
const { application, appuser } = await bindApplication('billing')
|
|
114
|
+
|
|
115
|
+
const { accessToken } = await mutation.renewApplicationAccessToken(appuser.id, context(), 'admin')
|
|
116
|
+
|
|
117
|
+
expect(jwt.decode(accessToken).application.appKey).toEqual(application.appKey)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('does not put the client secret in the access token', async () => {
|
|
121
|
+
/*
|
|
122
|
+
* ⚠ The defect. A JWT payload is base64, not encryption — whoever holds the token reads it.
|
|
123
|
+
* With the secret and the key, which is public, a holder can call `/oauth2/access-token` as
|
|
124
|
+
* that client.
|
|
125
|
+
*/
|
|
126
|
+
const { application, appuser } = await bindApplication('billing')
|
|
127
|
+
|
|
128
|
+
const { accessToken } = await mutation.renewApplicationAccessToken(appuser.id, context(), 'admin')
|
|
129
|
+
|
|
130
|
+
expect(JSON.stringify(jwt.decode(accessToken))).not.toContain(application.appSecret)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('does not put it in the refresh token either', async () => {
|
|
134
|
+
/*
|
|
135
|
+
* ⚠ The longer-lived half. The access token is 30 days; this one is a year, and it is the
|
|
136
|
+
* one handed to the client to keep.
|
|
137
|
+
*/
|
|
138
|
+
const { application, appuser } = await bindApplication('billing')
|
|
139
|
+
|
|
140
|
+
const { refreshToken } = await mutation.renewApplicationAccessToken(appuser.id, context(), 'admin')
|
|
141
|
+
|
|
142
|
+
expect(jwt.decode(refreshToken).application.appKey).toEqual(application.appKey)
|
|
143
|
+
expect(JSON.stringify(jwt.decode(refreshToken))).not.toContain(application.appSecret)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('does not leave it in the row it stores', async () => {
|
|
147
|
+
/*
|
|
148
|
+
* The refresh token is saved on the shadow user's `password`, so a payload carrying the
|
|
149
|
+
* secret writes the secret into a second place as well.
|
|
150
|
+
*/
|
|
151
|
+
const { application, appuser } = await bindApplication('billing')
|
|
152
|
+
|
|
153
|
+
await mutation.renewApplicationAccessToken(appuser.id, context(), 'admin')
|
|
154
|
+
|
|
155
|
+
const stored = await getRepository(User).findOneBy({ id: appuser.id })
|
|
156
|
+
|
|
157
|
+
expect(stored.password).toBeTruthy()
|
|
158
|
+
expect(JSON.stringify(jwt.decode(stored.password))).not.toContain(application.appSecret)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('mints a refresh token the OAuth2 exchange can resolve back to the application', async () => {
|
|
162
|
+
/*
|
|
163
|
+
* ⚠ The second face of the same defect, and the one an operator would notice. The refresh
|
|
164
|
+
* exchange reads `application.appKey` out of the token and does `findOneBy({ appKey })`
|
|
165
|
+
* (`router/oauth2/oauth2-server.ts`). A payload naming the secret finds no row, so the
|
|
166
|
+
* exchange answers "application is not exist" and the client cannot refresh at all.
|
|
167
|
+
*/
|
|
168
|
+
const { application, appuser } = await bindApplication('billing')
|
|
169
|
+
|
|
170
|
+
const { refreshToken } = await mutation.renewApplicationAccessToken(appuser.id, context(), 'admin')
|
|
171
|
+
const named = jwt.decode(refreshToken).application.appKey
|
|
172
|
+
|
|
173
|
+
expect(await getRepository(Application).findOneBy({ appKey: named })).toMatchObject({ id: application.id })
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('does not name one application with the key of another', async () => {
|
|
177
|
+
const a = await bindApplication('billing')
|
|
178
|
+
const b = await bindApplication('shipping')
|
|
179
|
+
|
|
180
|
+
const renewed = await mutation.renewApplicationAccessToken(a.appuser.id, context(), 'admin')
|
|
181
|
+
|
|
182
|
+
expect(jwt.decode(renewed.accessToken).application.appKey).toEqual(a.application.appKey)
|
|
183
|
+
expect(jwt.decode(renewed.accessToken).application.appKey).not.toEqual(b.application.appKey)
|
|
184
|
+
})
|
|
185
|
+
})
|