@things-factory/auth-base 10.1.20 → 10.1.24
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/service/appliance/appliance-mutation.d.ts +1 -0
- package/dist-server/service/appliance/appliance-mutation.js +74 -0
- package/dist-server/service/appliance/appliance-mutation.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/tests/appliance-revoke-db.test.ts +249 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/auth-base",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.24",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"browser": "dist-client/index.js",
|
|
6
6
|
"things-factory": true,
|
|
@@ -48,5 +48,5 @@
|
|
|
48
48
|
"passport-jwt": "^4.0.0",
|
|
49
49
|
"passport-local": "^1.0.0"
|
|
50
50
|
},
|
|
51
|
-
"gitHead": "
|
|
51
|
+
"gitHead": "0d88a504e0ffa63cd91279b88c6998d4bf1b3d86"
|
|
52
52
|
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `revokeApplianceCredential` — taking a device's access away while keeping the device.
|
|
3
|
+
*
|
|
4
|
+
* ── What was missing ────────────────────────────────────────────────────────
|
|
5
|
+
* There was no way to leave an appliance without a working credential. You could delete the
|
|
6
|
+
* row, which takes its history with it, or issue a new secret, which puts a **live** credential
|
|
7
|
+
* in the database and on the screen. Neither is revocation.
|
|
8
|
+
*
|
|
9
|
+
* ── Why the status is the switch, not the field ─────────────────────────────
|
|
10
|
+
* The access token is a signed JWT. Clearing `Appliance.accessToken` drops our copy and does
|
|
11
|
+
* nothing to the token already on the device — it keeps verifying against SECRET until its own
|
|
12
|
+
* `exp`, a year out. `User.checkAuth` is what refuses it, and it refuses on the shadow user's
|
|
13
|
+
* status. So these tests assert the status; the cleared fields are tidiness, and one case says
|
|
14
|
+
* so explicitly because the opposite is easy to assume.
|
|
15
|
+
*
|
|
16
|
+
* ⚠ sqlite (`tests/db.ts`). Enum columns fall to the varchar branch here, so this pins the
|
|
17
|
+
* value that is stored and read back, not the dialect's own enum behaviour.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
|
|
21
|
+
import { loadCompiled } from './compiled'
|
|
22
|
+
|
|
23
|
+
const jwt = require('jsonwebtoken')
|
|
24
|
+
|
|
25
|
+
const { Appliance } = loadCompiled('service/appliance/appliance')
|
|
26
|
+
const { User, UserStatus } = loadCompiled('service/user/user')
|
|
27
|
+
const { ApplianceMutation } = loadCompiled('service/appliance/appliance-mutation')
|
|
28
|
+
|
|
29
|
+
const mutation = new ApplianceMutation()
|
|
30
|
+
|
|
31
|
+
let domain: any
|
|
32
|
+
let actor: any
|
|
33
|
+
|
|
34
|
+
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
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function shadowUserOf(applianceId: string) {
|
|
43
|
+
return await getRepository(User).findOneBy({ reference: applianceId, userType: 'appliance' })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function makeAppliance(name: string) {
|
|
47
|
+
const appliance = await mutation.createAppliance(
|
|
48
|
+
{ name, serialNo: `${name}-SN`, brand: 'Acme', model: 'X1' },
|
|
49
|
+
context()
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
/* Registering does not issue a token; the screen shows "not issued" until someone asks. */
|
|
53
|
+
return await mutation.generateApplianceSecret(appliance.id, context())
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
beforeAll(async () => {
|
|
57
|
+
await openAuthDatabase()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
afterAll(async () => {
|
|
61
|
+
await closeAuthDatabase()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
beforeEach(async () => {
|
|
65
|
+
await resetAuthDatabase()
|
|
66
|
+
|
|
67
|
+
domain = await getRepository(Domain).save({ name: 'acme', subdomain: 'acme' })
|
|
68
|
+
actor = await getRepository(User).save({
|
|
69
|
+
username: 'operator@acme.z',
|
|
70
|
+
email: 'operator@acme.z',
|
|
71
|
+
name: 'operator',
|
|
72
|
+
status: UserStatus.ACTIVATED,
|
|
73
|
+
domains: [domain]
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('revoking an appliance credential', () => {
|
|
78
|
+
it('leaves the appliance, and leaves it with nothing to authenticate with', async () => {
|
|
79
|
+
const issued = await makeAppliance('gate-a')
|
|
80
|
+
|
|
81
|
+
expect(issued.accessToken).toBeTruthy()
|
|
82
|
+
expect((await shadowUserOf(issued.id))!.status).toEqual(UserStatus.ACTIVATED)
|
|
83
|
+
|
|
84
|
+
const revoked = await mutation.revokeApplianceCredential(issued.id, context())
|
|
85
|
+
|
|
86
|
+
/* The record survives — that is the whole difference from deleteAppliance. */
|
|
87
|
+
expect(revoked.id).toEqual(issued.id)
|
|
88
|
+
expect(revoked.name).toEqual('gate-a')
|
|
89
|
+
expect(await getRepository(Appliance).findOneBy({ id: issued.id })).toBeTruthy()
|
|
90
|
+
|
|
91
|
+
expect(revoked.accessToken).toBeNull()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('turns the shadow user inactive — the switch every request reads', async () => {
|
|
95
|
+
/*
|
|
96
|
+
* ⚠ The case the feature exists for. `checkAuth` throws USER_NOT_ACTIVATED on INACTIVE, so
|
|
97
|
+
* this is what refuses the token that is already out on the device. Without it the cleared
|
|
98
|
+
* field means nothing: the JWT verifies on its signature alone.
|
|
99
|
+
*/
|
|
100
|
+
const issued = await makeAppliance('gate-b')
|
|
101
|
+
|
|
102
|
+
await mutation.revokeApplianceCredential(issued.id, context())
|
|
103
|
+
|
|
104
|
+
const appuser = await shadowUserOf(issued.id)
|
|
105
|
+
|
|
106
|
+
expect(appuser).toBeTruthy()
|
|
107
|
+
expect(appuser!.status).toEqual(UserStatus.INACTIVE)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('keeps the shadow user rather than deleting it', async () => {
|
|
111
|
+
/*
|
|
112
|
+
* `deleteAppliance` deletes it, and that is right when the appliance goes too. Here the
|
|
113
|
+
* appliance stays, so the identity has to stay with it — past rows point at it as creator
|
|
114
|
+
* and updater, and re-issuing keeps the same id rather than orphaning them.
|
|
115
|
+
*/
|
|
116
|
+
const issued = await makeAppliance('gate-c')
|
|
117
|
+
const before = await shadowUserOf(issued.id)
|
|
118
|
+
|
|
119
|
+
await mutation.revokeApplianceCredential(issued.id, context())
|
|
120
|
+
|
|
121
|
+
const after = await shadowUserOf(issued.id)
|
|
122
|
+
|
|
123
|
+
expect(after).toBeTruthy()
|
|
124
|
+
expect(after!.id).toEqual(before!.id)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('stops holding a copy of the credential it just killed', async () => {
|
|
128
|
+
/*
|
|
129
|
+
* Tidiness, not a control — and worth saying so, because the opposite is easy to believe.
|
|
130
|
+
* The JWT verifies on its signature, never against this column, so clearing it does not
|
|
131
|
+
* stop the token on the device. The status does that.
|
|
132
|
+
*
|
|
133
|
+
* ⚠ Which leaves a real hazard this does NOT close: put the status back to ACTIVATED
|
|
134
|
+
* without issuing a new secret and the old token authenticates again, cleared column or
|
|
135
|
+
* not. Nothing here prevents that. Closing it needs the token itself to be refusable —
|
|
136
|
+
* a jti denylist, or re-issuing as part of reactivation.
|
|
137
|
+
*/
|
|
138
|
+
const issued = await makeAppliance('gate-d')
|
|
139
|
+
|
|
140
|
+
expect((await shadowUserOf(issued.id))!.password).toBeTruthy()
|
|
141
|
+
|
|
142
|
+
await mutation.revokeApplianceCredential(issued.id, context())
|
|
143
|
+
|
|
144
|
+
expect((await shadowUserOf(issued.id))!.password).toBeNull()
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('lets a new secret work afterwards', async () => {
|
|
148
|
+
/*
|
|
149
|
+
* A revoked appliance is INACTIVE, and `checkAuth` refuses that whatever the token says. So
|
|
150
|
+
* issuing again has to put the status back, or the new credential is dead on arrival — a
|
|
151
|
+
* failure that would look like the token being wrong.
|
|
152
|
+
*/
|
|
153
|
+
const issued = await makeAppliance('gate-e')
|
|
154
|
+
await mutation.revokeApplianceCredential(issued.id, context())
|
|
155
|
+
|
|
156
|
+
const reissued = await mutation.generateApplianceSecret(issued.id, context())
|
|
157
|
+
|
|
158
|
+
expect(reissued.accessToken).toBeTruthy()
|
|
159
|
+
|
|
160
|
+
const appuser = await shadowUserOf(issued.id)
|
|
161
|
+
|
|
162
|
+
expect(appuser!.status).toEqual(UserStatus.ACTIVATED)
|
|
163
|
+
expect(appuser!.password).toEqual(reissued.accessToken)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('⚠ hands back the very token it just revoked, when re-issued in the same second', async () => {
|
|
167
|
+
/*
|
|
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.
|
|
172
|
+
*
|
|
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.
|
|
180
|
+
*/
|
|
181
|
+
const issued = await makeAppliance('gate-g')
|
|
182
|
+
|
|
183
|
+
await mutation.revokeApplianceCredential(issued.id, context())
|
|
184
|
+
const reissued = await mutation.generateApplianceSecret(issued.id, context())
|
|
185
|
+
|
|
186
|
+
expect(reissued.accessToken).toEqual(issued.accessToken)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('refuses an appliance that belongs to another domain', async () => {
|
|
190
|
+
/*
|
|
191
|
+
* Revocation stops a device. Reaching across a tenant boundary with it would let one
|
|
192
|
+
* customer turn off another's equipment.
|
|
193
|
+
*/
|
|
194
|
+
const issued = await makeAppliance('gate-f')
|
|
195
|
+
const stranger = await getRepository(Domain).save({ name: 'other', subdomain: 'other' })
|
|
196
|
+
|
|
197
|
+
await expect(mutation.revokeApplianceCredential(issued.id, context(stranger))).rejects.toThrow()
|
|
198
|
+
|
|
199
|
+
const appuser = await shadowUserOf(issued.id)
|
|
200
|
+
|
|
201
|
+
expect(appuser!.status).toEqual(UserStatus.ACTIVATED)
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
describe('what an already-issued token can still do', () => {
|
|
206
|
+
/*
|
|
207
|
+
* The token is a signed JWT. `checkAuth` is the only thing that looks at our records once the
|
|
208
|
+
* signature verifies, so these cases run the real token through the real function rather than
|
|
209
|
+
* reasoning about it.
|
|
210
|
+
*/
|
|
211
|
+
function decode(token: string) {
|
|
212
|
+
return jwt.decode(token)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
it('is refused while the appliance is revoked', async () => {
|
|
216
|
+
const issued = await makeAppliance('gate-h')
|
|
217
|
+
|
|
218
|
+
await expect(User.checkAuth(decode(issued.accessToken))).resolves.toBeTruthy()
|
|
219
|
+
|
|
220
|
+
await mutation.revokeApplianceCredential(issued.id, context())
|
|
221
|
+
|
|
222
|
+
await expect(User.checkAuth(decode(issued.accessToken))).rejects.toThrow()
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('⚠ works again the moment the status goes back, with no new secret issued', async () => {
|
|
226
|
+
/*
|
|
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.
|
|
231
|
+
*
|
|
232
|
+
* Clearing the columns does not prevent it, and this test exists so nobody believes it does.
|
|
233
|
+
*/
|
|
234
|
+
const issued = await makeAppliance('gate-i')
|
|
235
|
+
const oldToken = issued.accessToken
|
|
236
|
+
|
|
237
|
+
await mutation.revokeApplianceCredential(issued.id, context())
|
|
238
|
+
await expect(User.checkAuth(decode(oldToken))).rejects.toThrow()
|
|
239
|
+
|
|
240
|
+
const appuser = await shadowUserOf(issued.id)
|
|
241
|
+
await getRepository(User).save({ ...appuser, status: UserStatus.ACTIVATED })
|
|
242
|
+
|
|
243
|
+
const subject = await User.checkAuth(decode(oldToken))
|
|
244
|
+
|
|
245
|
+
expect(subject).toBeTruthy()
|
|
246
|
+
expect(subject.id).toEqual(appuser!.id)
|
|
247
|
+
expect(subject.password).toBeNull()
|
|
248
|
+
})
|
|
249
|
+
})
|