@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.
Files changed (108) hide show
  1. package/dist-server/controllers/invitation.d.ts +47 -10
  2. package/dist-server/controllers/invitation.js +159 -113
  3. package/dist-server/controllers/invitation.js.map +1 -1
  4. package/dist-server/controllers/profile.d.ts +1 -0
  5. package/dist-server/router/auth-public-process-router.js +36 -13
  6. package/dist-server/router/auth-public-process-router.js.map +1 -1
  7. package/dist-server/router/oauth2/oauth2-router.js +7 -2
  8. package/dist-server/router/oauth2/oauth2-router.js.map +1 -1
  9. package/dist-server/router/oauth2/oauth2-server.js +4 -4
  10. package/dist-server/router/oauth2/oauth2-server.js.map +1 -1
  11. package/dist-server/service/app-binding/app-binding-mutation.d.ts +27 -0
  12. package/dist-server/service/app-binding/app-binding-mutation.js +39 -6
  13. package/dist-server/service/app-binding/app-binding-mutation.js.map +1 -1
  14. package/dist-server/service/app-binding/app-binding-query.js +14 -7
  15. package/dist-server/service/app-binding/app-binding-query.js.map +1 -1
  16. package/dist-server/service/app-binding/app-binding.d.ts +9 -0
  17. package/dist-server/service/app-binding/app-binding.js +10 -1
  18. package/dist-server/service/app-binding/app-binding.js.map +1 -1
  19. package/dist-server/service/appliance/appliance-mutation.js +34 -1
  20. package/dist-server/service/appliance/appliance-mutation.js.map +1 -1
  21. package/dist-server/service/appliance/appliance-query.d.ts +2 -0
  22. package/dist-server/service/appliance/appliance-query.js +48 -0
  23. package/dist-server/service/appliance/appliance-query.js.map +1 -1
  24. package/dist-server/service/appliance/appliance.d.ts +1 -0
  25. package/dist-server/service/appliance/appliance.js +33 -3
  26. package/dist-server/service/appliance/appliance.js.map +1 -1
  27. package/dist-server/service/application/application-mutation.js +51 -6
  28. package/dist-server/service/application/application-mutation.js.map +1 -1
  29. package/dist-server/service/application/application.d.ts +9 -3
  30. package/dist-server/service/application/application.js +24 -8
  31. package/dist-server/service/application/application.js.map +1 -1
  32. package/dist-server/service/auth-provider/auth-provider-mutation.js +5 -0
  33. package/dist-server/service/auth-provider/auth-provider-mutation.js.map +1 -1
  34. package/dist-server/service/domain-generator/domain-generator-mutation.js +3 -0
  35. package/dist-server/service/domain-generator/domain-generator-mutation.js.map +1 -1
  36. package/dist-server/service/invitation/invitation-mutation.d.ts +17 -15
  37. package/dist-server/service/invitation/invitation-mutation.js +83 -56
  38. package/dist-server/service/invitation/invitation-mutation.js.map +1 -1
  39. package/dist-server/service/invitation/invitation-query.d.ts +20 -5
  40. package/dist-server/service/invitation/invitation-query.js +60 -19
  41. package/dist-server/service/invitation/invitation-query.js.map +1 -1
  42. package/dist-server/service/invitation/invitation.d.ts +14 -2
  43. package/dist-server/service/invitation/invitation.js +77 -8
  44. package/dist-server/service/invitation/invitation.js.map +1 -1
  45. package/dist-server/service/login-history/login-history-query.js +3 -0
  46. package/dist-server/service/login-history/login-history-query.js.map +1 -1
  47. package/dist-server/service/role/role-mutation.js +14 -15
  48. package/dist-server/service/role/role-mutation.js.map +1 -1
  49. package/dist-server/service/role/role-query.js +26 -4
  50. package/dist-server/service/role/role-query.js.map +1 -1
  51. package/dist-server/service/role/role-types.d.ts +2 -0
  52. package/dist-server/service/role/role-types.js +8 -0
  53. package/dist-server/service/role/role-types.js.map +1 -1
  54. package/dist-server/service/role-template/role-template-mutation.d.ts +17 -1
  55. package/dist-server/service/role-template/role-template-mutation.js +14 -3
  56. package/dist-server/service/role-template/role-template-mutation.js.map +1 -1
  57. package/dist-server/service/user/user-mutation.js +1 -0
  58. package/dist-server/service/user/user-mutation.js.map +1 -1
  59. package/dist-server/service/user/user.d.ts +1 -0
  60. package/dist-server/service/user/user.js +19 -4
  61. package/dist-server/service/user/user.js.map +1 -1
  62. package/dist-server/templates/invitation-email.d.ts +2 -1
  63. package/dist-server/templates/invitation-email.js +16 -4
  64. package/dist-server/templates/invitation-email.js.map +1 -1
  65. package/dist-server/tsconfig.tsbuildinfo +1 -1
  66. package/dist-server/utils/credential-at-rest.d.ts +28 -0
  67. package/dist-server/utils/credential-at-rest.js +36 -0
  68. package/dist-server/utils/credential-at-rest.js.map +1 -0
  69. package/dist-server/utils/credential-features.d.ts +35 -0
  70. package/dist-server/utils/credential-features.js +45 -0
  71. package/dist-server/utils/credential-features.js.map +1 -0
  72. package/dist-server/utils/credential-serial-rule.d.ts +41 -0
  73. package/dist-server/utils/credential-serial-rule.js +43 -0
  74. package/dist-server/utils/credential-serial-rule.js.map +1 -0
  75. package/dist-server/utils/invitation-state.d.ts +52 -0
  76. package/dist-server/utils/invitation-state.js +76 -0
  77. package/dist-server/utils/invitation-state.js.map +1 -0
  78. package/dist-server/utils/refuse-role-name.d.ts +42 -0
  79. package/dist-server/utils/refuse-role-name.js +76 -0
  80. package/dist-server/utils/refuse-role-name.js.map +1 -0
  81. package/dist-server/utils/role-name-standing.d.ts +58 -0
  82. package/dist-server/utils/role-name-standing.js +72 -0
  83. package/dist-server/utils/role-name-standing.js.map +1 -0
  84. package/package.json +4 -4
  85. package/tests/app-binding-delete-db.test.ts +188 -0
  86. package/tests/appliance-credential-state-db.test.ts +207 -0
  87. package/tests/appliance-delete-db.test.ts +119 -0
  88. package/tests/appliance-revoke-db.test.ts +73 -28
  89. package/tests/appliance-schema.test.ts +129 -0
  90. package/tests/application-delete-db.test.ts +190 -0
  91. package/tests/application-token-payload-db.test.ts +185 -0
  92. package/tests/checkin-privilege-seam-db.test.ts +158 -0
  93. package/tests/credential-at-rest-db.test.ts +246 -0
  94. package/tests/credential-features-off-db.test.ts +189 -0
  95. package/tests/credential-serial-rule.test.ts +84 -0
  96. package/tests/invitation-db.test.ts +416 -0
  97. package/tests/invitation-state.test.ts +92 -0
  98. package/tests/open-doors.test.ts +132 -0
  99. package/tests/role-inheritance-db.test.ts +286 -0
  100. package/tests/role-mutation-db.test.ts +21 -7
  101. package/tests/role-name-lookup-sentinel.test.ts +79 -0
  102. package/tests/role-name-standing.test.ts +83 -0
  103. package/tests/token-issuance.test.ts +7 -3
  104. package/translations/en.json +2 -0
  105. package/translations/ja.json +2 -0
  106. package/translations/ko.json +2 -0
  107. package/translations/ms.json +2 -0
  108. 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 { state: { domain: d, user: actor, tx: undefined }, throw: (code: number, message: string) => {
36
- const error: any = new Error(message)
37
- error.status = code
38
- throw error
39
- } } as any
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('⚠ hands back the very token it just revoked, when re-issued in the same second', async () => {
210
+ it('does not hand back the token it just revoked, even re-issued in the same second', async () => {
167
211
  /*
168
- * This pins a defect, not a guarantee. `Appliance.sign` puts `{ id, userType, appliance.id,
169
- * status, domain.subdomain }` in the payload and nothing else — no jti, no nonce — and `iat`
170
- * and `exp` are whole seconds. So two signings inside one second produce a byte-identical
171
- * string, and "generate a new access token" returns the credential that was just revoked.
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
- * It matters together with the hazard above: the status goes back to ACTIVATED on re-issue,
174
- * and the token is the same one, so anything still holding the revoked token is back in.
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('⚠ works again the moment the status goes back, with no new secret issued', async () => {
268
+ it('stays refused when the status goes back, because the serial moved', async () => {
226
269
  /*
227
- * This is the hazard, run rather than argued. Revocation lives entirely in the shadow user's
228
- * status: the signature is still good, `exp` is a year out, and nothing consults the cleared
229
- * `password` column. So putting the status back — by hand, by a future screen, by a fixture —
230
- * restores every token that was ever issued to this appliance.
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
- * Clearing the columns does not prevent it, and this test exists so nobody believes it does.
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
- const subject = await User.checkAuth(decode(oldToken))
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(subject).toBeTruthy()
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
+ })