lacewing 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +68 -0
- package/GETTING-STARTED.md +654 -0
- package/README.md +53 -0
- package/SECURITY.md +22 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/src/extension.js +16 -0
- package/dist/src/extension.js.map +1 -0
- package/dist/src/http/bearer.js +6 -10
- package/dist/src/http/bearer.js.map +1 -1
- package/dist/src/http/cookies.js +7 -10
- package/dist/src/http/cookies.js.map +1 -1
- package/dist/src/http/source.js +49 -0
- package/dist/src/http/source.js.map +1 -0
- package/dist/src/key/import.js +2 -1
- package/dist/src/key/import.js.map +1 -1
- package/dist/src/lib/algorithms.js +9 -2
- package/dist/src/lib/algorithms.js.map +1 -1
- package/dist/src/lib/duration.js +3 -0
- package/dist/src/lib/duration.js.map +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/src/extension.d.ts +16 -0
- package/dist/types/src/extension.d.ts.map +1 -0
- package/dist/types/src/http/bearer.d.ts +6 -3
- package/dist/types/src/http/bearer.d.ts.map +1 -1
- package/dist/types/src/http/cookies.d.ts +7 -3
- package/dist/types/src/http/cookies.d.ts.map +1 -1
- package/dist/types/src/http/source.d.ts +27 -0
- package/dist/types/src/http/source.d.ts.map +1 -0
- package/dist/types/src/key/import.d.ts.map +1 -1
- package/dist/types/src/lib/algorithms.d.ts +4 -0
- package/dist/types/src/lib/algorithms.d.ts.map +1 -1
- package/dist/types/src/lib/duration.d.ts.map +1 -1
- package/package.json +13 -8
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Getting started
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Getting started with Lacewing
|
|
6
|
+
|
|
7
|
+
Adopting Lacewing in a real service: keys, profiles, issuing, verifying,
|
|
8
|
+
transport, refresh tokens, revocation, and key distribution.
|
|
9
|
+
|
|
10
|
+
Every code sample below was executed against the library before publishing.
|
|
11
|
+
|
|
12
|
+
For the pitch and the API reference, see the [README](./README.md).
|
|
13
|
+
|
|
14
|
+
**Contents**
|
|
15
|
+
|
|
16
|
+
1. [Before you start](#1-before-you-start)
|
|
17
|
+
2. [A working example](#2-a-working-example)
|
|
18
|
+
3. [Keys](#3-keys)
|
|
19
|
+
4. [Profiles](#4-profiles)
|
|
20
|
+
5. [Issuing tokens](#5-issuing-tokens)
|
|
21
|
+
6. [Verifying tokens](#6-verifying-tokens)
|
|
22
|
+
7. [Getting the token out of a request](#7-getting-the-token-out-of-a-request)
|
|
23
|
+
8. [Access and refresh tokens](#8-access-and-refresh-tokens)
|
|
24
|
+
9. [Revocation](#9-revocation)
|
|
25
|
+
10. [Distributing keys in production](#10-distributing-keys-in-production)
|
|
26
|
+
11. [Handling errors](#11-handling-errors)
|
|
27
|
+
12. [Coming from `jsonwebtoken`](#12-coming-from-jsonwebtoken)
|
|
28
|
+
13. [Adoption checklist](#13-adoption-checklist)
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## 1. Before you start
|
|
33
|
+
|
|
34
|
+
Lacewing requires Node 24 or later and is ESM only. There is no CommonJS
|
|
35
|
+
build and no `require()` path.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npm install lacewing
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Your `package.json` needs `"type": "module"`. If you are on CommonJS, that
|
|
42
|
+
migration comes first.
|
|
43
|
+
|
|
44
|
+
TypeScript is not required, but most of the design assumes it. `VerifiedJwt`
|
|
45
|
+
is a branded type that only `jwtVerify` produces, so handing an unverified
|
|
46
|
+
token to code expecting verified claims fails to compile. From plain
|
|
47
|
+
JavaScript you lose that check.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## 2. A working example
|
|
52
|
+
|
|
53
|
+
Copy this into `demo.mts` and run `node --experimental-strip-types demo.mts`:
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
import { generateKeyPair, SignJWT, defineProfile, jwtVerify } from "lacewing";
|
|
57
|
+
|
|
58
|
+
const ISSUER = "https://auth.example.com";
|
|
59
|
+
const AUDIENCE = "https://api.example.com";
|
|
60
|
+
|
|
61
|
+
// 1. Keys. EdDSA by default.
|
|
62
|
+
const { publicKey, privateKey } = await generateKeyPair();
|
|
63
|
+
|
|
64
|
+
// 2. A profile: everything the verifier will demand. Define it once.
|
|
65
|
+
const profile = defineProfile({
|
|
66
|
+
typ: "at+jwt",
|
|
67
|
+
issuer: ISSUER,
|
|
68
|
+
audience: AUDIENCE,
|
|
69
|
+
algorithms: ["EdDSA"],
|
|
70
|
+
keys: publicKey,
|
|
71
|
+
maxTokenAge: "15m",
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// 3. Issue.
|
|
75
|
+
const token = await new SignJWT("at+jwt")
|
|
76
|
+
.issuer(ISSUER)
|
|
77
|
+
.audience(AUDIENCE)
|
|
78
|
+
.subject("user-42")
|
|
79
|
+
.claim("scope", "read:documents")
|
|
80
|
+
.expiresIn("10m")
|
|
81
|
+
.sign(privateKey);
|
|
82
|
+
|
|
83
|
+
// 4. Verify. The only way to turn a string into trusted claims.
|
|
84
|
+
const { payload } = await jwtVerify(token, profile);
|
|
85
|
+
console.log(payload.sub, payload.scope); // user-42 read:documents
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## 3. Keys
|
|
91
|
+
|
|
92
|
+
### Generating
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { generateKeyPair, generateSecret } from "lacewing";
|
|
96
|
+
|
|
97
|
+
const { publicKey, privateKey } = await generateKeyPair(); // EdDSA
|
|
98
|
+
const { publicKey: p2, privateKey: k2 } = await generateKeyPair("ES256");
|
|
99
|
+
const secret = generateSecret("HS256"); // symmetric
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Supported algorithms: `EdDSA` (default), `ES256`/`ES384`/`ES512`,
|
|
103
|
+
`PS256`/`PS384`/`PS512`, `HS256`/`HS384`/`HS512`. The `RS*` family is not
|
|
104
|
+
registered at all. Using it means importing `lacewing/legacy/rsa` and calling
|
|
105
|
+
`enableLegacyRS256()` (or `enableLegacyRSA()` for the whole family), which is
|
|
106
|
+
the marker code review looks for. Prefer `PS256` or `EdDSA` where you control
|
|
107
|
+
both sides.
|
|
108
|
+
|
|
109
|
+
Use `EdDSA` unless you have a reason not to. Under `HS256` everyone who can
|
|
110
|
+
verify a token can also mint one, so your API servers can impersonate your
|
|
111
|
+
auth server.
|
|
112
|
+
|
|
113
|
+
A key is bound to one algorithm at import time. `LacewingKey<"ES256">`
|
|
114
|
+
cannot be passed where `LacewingKey<"HS256">` is expected, so algorithm
|
|
115
|
+
confusion is a compile error.
|
|
116
|
+
|
|
117
|
+
### Generated private keys cannot be exported
|
|
118
|
+
|
|
119
|
+
`generateKeyPair()` produces a non-extractable private key by default, so it
|
|
120
|
+
cannot leave through `exportKeyPEM`:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
const { privateKey } = await generateKeyPair();
|
|
124
|
+
await exportKeyPEM(privateKey); // throws KeyExportFailed
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
That suits a key generated at boot, but it means you cannot generate a key
|
|
128
|
+
and then save it. To persist one, ask for it:
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
const { publicKey, privateKey } = await generateKeyPair("EdDSA", {
|
|
132
|
+
extractable: true,
|
|
133
|
+
});
|
|
134
|
+
const pem = await exportKeyPEM(privateKey); // now works; store it somewhere safe
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Public keys export either way.
|
|
138
|
+
|
|
139
|
+
### Loading existing keys
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
import { importKey } from "lacewing";
|
|
143
|
+
|
|
144
|
+
const signing = await importKey(process.env.PRIVATE_KEY_PEM!, "EdDSA");
|
|
145
|
+
const fromJwk = await importKey({ kty: "OKP", crv: "Ed25519", x: "..." }, "EdDSA");
|
|
146
|
+
const hmac = await importKey(process.env.SECRET!, "HS256");
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`importKey` runs an entropy check on HMAC secrets, so a password-shaped
|
|
150
|
+
secret fails at startup instead of producing weak signatures:
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
await importKey("my-secret-password-123", "HS256"); // throws EntropyCheckFailed
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
If that fires on a secret you already use, replace the secret.
|
|
157
|
+
`generateSecret("HS256")` produces one at the algorithm's minimum size.
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## 4. Profiles
|
|
162
|
+
|
|
163
|
+
A profile is the complete set of demands a verifier makes, and the only
|
|
164
|
+
argument shape `jwtVerify` accepts. That is what makes `typ`, `issuer`,
|
|
165
|
+
`audience`, `algorithms` and `keys` impossible to omit.
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
import { defineProfile } from "lacewing";
|
|
169
|
+
|
|
170
|
+
const profile = defineProfile({
|
|
171
|
+
typ: "at+jwt", // required: which kind of token
|
|
172
|
+
issuer: "https://auth.example.com",
|
|
173
|
+
audience: "https://api.example.com",
|
|
174
|
+
algorithms: ["EdDSA"], // the header never gets a vote
|
|
175
|
+
keys: publicKey,
|
|
176
|
+
maxTokenAge: "15m", // required: max age by iat, independent of exp
|
|
177
|
+
|
|
178
|
+
// optional
|
|
179
|
+
maxTokenLifetime: "1h", // refuse an implausible declared exp-iat span
|
|
180
|
+
maxClockSkew: "5s", // default 5s, capped at 120s
|
|
181
|
+
subject: "user-42", // pin the expected sub
|
|
182
|
+
revocation: store,
|
|
183
|
+
claimValidators: {
|
|
184
|
+
scope: (value) => {
|
|
185
|
+
if (typeof value !== "string" || !value.includes("read")) {
|
|
186
|
+
throw new Error("scope must include read");
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Define each profile once at module scope and export it. A profile is inert
|
|
194
|
+
configuration; building one per request wastes work and lets two call sites
|
|
195
|
+
disagree about what is acceptable.
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
// auth/profiles.ts: the whole app imports from here
|
|
199
|
+
export const apiProfile = defineProfile({ /* ... */ });
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
`maxTokenAge` and `exp` are separate controls and you want both. `exp` is
|
|
203
|
+
what the issuer claimed. `maxTokenAge` is what you accept regardless, so an
|
|
204
|
+
issuer that starts handing out 30-day access tokens does not widen your
|
|
205
|
+
window.
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## 5. Issuing tokens
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
import { SignJWT } from "lacewing";
|
|
213
|
+
|
|
214
|
+
const token = await new SignJWT("at+jwt") // typ is a constructor argument
|
|
215
|
+
.issuer("https://auth.example.com")
|
|
216
|
+
.audience("https://api.example.com")
|
|
217
|
+
.subject("user-42")
|
|
218
|
+
.claim("scope", "read:documents")
|
|
219
|
+
.claim("tenant", "acme")
|
|
220
|
+
.expiresIn("10m")
|
|
221
|
+
.sign(privateKey);
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
`.sign()` refuses to run without `issuer`, `audience` and `expiresIn`. Each
|
|
225
|
+
is waivable through a method named to show up in code review:
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
await new SignJWT("at+jwt")
|
|
229
|
+
.audience("...")
|
|
230
|
+
.expiresIn("5m")
|
|
231
|
+
.unsafeAllowMissingIssuer() // grep for `unsafeAllow` in CI
|
|
232
|
+
.sign(privateKey);
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Applied automatically, with no configuration:
|
|
236
|
+
|
|
237
|
+
- `jti`: a unique UUID on every token, so revocation always has a key
|
|
238
|
+
- `iat`: set at sign time
|
|
239
|
+
- Lifetime cap: `.expiresIn()` above 1h throws `MaxLifetimeExceeded`. Raise
|
|
240
|
+
it with `new SignJWT("at+jwt", { maxLifetime: "24h" })`
|
|
241
|
+
- Payload hygiene: the payload is scanned before signing and refuses
|
|
242
|
+
anything shaped like a password, card number, or PEM key
|
|
243
|
+
|
|
244
|
+
A JWS payload is base64url-encoded plaintext, readable by anyone holding the
|
|
245
|
+
token. That is what the last check is for:
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
await new SignJWT("at+jwt")
|
|
249
|
+
.issuer(ISSUER).audience(AUDIENCE).expiresIn("5m")
|
|
250
|
+
.claim("password", "hunter2")
|
|
251
|
+
.sign(privateKey); // throws PayloadHygieneViolation
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
For a false positive, allow that claim by name:
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
.unsafeAllowClaim("password_changed_at")
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## 6. Verifying tokens
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
import { jwtVerify } from "lacewing";
|
|
266
|
+
|
|
267
|
+
const verified = await jwtVerify(token, profile);
|
|
268
|
+
verified.payload.sub; // trusted
|
|
269
|
+
verified.header.alg; // trusted
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
`jwtVerify` returns a fully checked `VerifiedJwt` or throws. There is no
|
|
273
|
+
partial result and no decoded-but-unverified object to trust by accident.
|
|
274
|
+
Checks run fail-fast in this order: structure, header, key resolution,
|
|
275
|
+
signature, claims, your custom validators, revocation.
|
|
276
|
+
|
|
277
|
+
Revocation runs last so unauthenticated input never reaches your revocation
|
|
278
|
+
store.
|
|
279
|
+
|
|
280
|
+
### Reading a token you have not verified
|
|
281
|
+
|
|
282
|
+
Debugging and logging sometimes need the contents of an unverified token.
|
|
283
|
+
`unsafeDecode` returns one, branded `UntrustedJwt` and type-incompatible
|
|
284
|
+
with `VerifiedJwt`:
|
|
285
|
+
|
|
286
|
+
```ts
|
|
287
|
+
import { unsafeDecode } from "lacewing";
|
|
288
|
+
|
|
289
|
+
const { header, payload } = unsafeDecode(token); // never trust these
|
|
290
|
+
console.log("token was issued by", payload.iss);
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
## 7. Getting the token out of a request
|
|
296
|
+
|
|
297
|
+
Two transports. Both refuse ambiguous input rather than resolving it.
|
|
298
|
+
|
|
299
|
+
```ts
|
|
300
|
+
import { parseBearer, readTokenCookie, setTokenCookie, buildTokenCookie } from "lacewing";
|
|
301
|
+
|
|
302
|
+
const token = parseBearer(request); // strict RFC 6750
|
|
303
|
+
const fromCookie = readTokenCookie(request); // __Host-token by default
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
Do not put tokens in `localStorage` or `sessionStorage`. Both are readable
|
|
307
|
+
by any script on the page, so one XSS is total token theft. Use an
|
|
308
|
+
`HttpOnly` cookie in browsers and an in-memory bearer token between
|
|
309
|
+
services.
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
setTokenCookie(response.headers, token);
|
|
313
|
+
// __Host-token=...; Path=/; HttpOnly; Secure; SameSite=Lax
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
`HttpOnly`, `Secure` and `SameSite` are always emitted, there is no option
|
|
317
|
+
to remove them, and `SameSite=None` throws. The default name uses the
|
|
318
|
+
`__Host-` prefix, which binds the cookie to the exact origin and forbids
|
|
319
|
+
`Domain`.
|
|
320
|
+
|
|
321
|
+
To log someone out:
|
|
322
|
+
|
|
323
|
+
```ts
|
|
324
|
+
import { clearTokenCookie } from "lacewing";
|
|
325
|
+
clearTokenCookie(response.headers);
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
### On Node frameworks
|
|
329
|
+
|
|
330
|
+
Both readers take WHATWG `Headers`, anything carrying them such as
|
|
331
|
+
`Request`, or the raw header string. Express, Fastify and Koa hand you a
|
|
332
|
+
plain object instead, so convert once at the edge:
|
|
333
|
+
|
|
334
|
+
```ts
|
|
335
|
+
app.use((req, res, next) => {
|
|
336
|
+
const headers = new Headers(req.headers as Record<string, string>);
|
|
337
|
+
req.token = readTokenCookie(headers) ?? parseBearer(headers);
|
|
338
|
+
next();
|
|
339
|
+
});
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
Passing the raw `req` throws a `TypeError` naming this fix. It does not read
|
|
343
|
+
as "no token".
|
|
344
|
+
|
|
345
|
+
The response side is a string, so it works anywhere:
|
|
346
|
+
|
|
347
|
+
```ts
|
|
348
|
+
res.setHeader("Set-Cookie", buildTokenCookie(token)); // Express
|
|
349
|
+
reply.header("Set-Cookie", buildTokenCookie(token)); // Fastify
|
|
350
|
+
c.header("Set-Cookie", buildTokenCookie(token)); // Hono
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
---
|
|
354
|
+
|
|
355
|
+
## 8. Access and refresh tokens
|
|
356
|
+
|
|
357
|
+
The most common token-confusion bug is a refresh token buying API access, or
|
|
358
|
+
an access token minting new sessions. Both profiles ship with the library:
|
|
359
|
+
|
|
360
|
+
```ts
|
|
361
|
+
import {
|
|
362
|
+
accessTokenProfile, refreshTokenProfile,
|
|
363
|
+
newAccessToken, newRefreshToken,
|
|
364
|
+
} from "lacewing";
|
|
365
|
+
|
|
366
|
+
export const apiProfile = accessTokenProfile({
|
|
367
|
+
issuer: "https://auth.example.com",
|
|
368
|
+
audience: "https://api.example.com", // the API
|
|
369
|
+
algorithms: ["EdDSA"],
|
|
370
|
+
keys: { jwksUri: "https://auth.example.com/jwks" },
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
export const refreshProfile = refreshTokenProfile({
|
|
374
|
+
issuer: "https://auth.example.com",
|
|
375
|
+
audience: "https://auth.example.com/token", // the auth server, never the API
|
|
376
|
+
algorithms: ["EdDSA"],
|
|
377
|
+
keys: publicKey,
|
|
378
|
+
revocation: store, // long-lived means revocable
|
|
379
|
+
});
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
| | access | refresh |
|
|
383
|
+
|---|---|---|
|
|
384
|
+
| `typ` | `at+jwt` | `rt+jwt` |
|
|
385
|
+
| audience | your API | your auth server's token endpoint |
|
|
386
|
+
| default max age | 10m | 30d |
|
|
387
|
+
| lifetime cap when signing | 1h | 90d |
|
|
388
|
+
| revocation | optional | required in practice |
|
|
389
|
+
|
|
390
|
+
Issuing:
|
|
391
|
+
|
|
392
|
+
```ts
|
|
393
|
+
const access = await newAccessToken()
|
|
394
|
+
.issuer(ISSUER).audience(API).subject("user-42").expiresIn("10m").sign(privateKey);
|
|
395
|
+
|
|
396
|
+
const refresh = await newRefreshToken()
|
|
397
|
+
.issuer(ISSUER).audience(`${ISSUER}/token`).subject("user-42").expiresIn("30d").sign(privateKey);
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
`typ` does the work. Even with identical keys, claims and audience, each
|
|
401
|
+
profile refuses the other's tokens:
|
|
402
|
+
|
|
403
|
+
```ts
|
|
404
|
+
// throws JWTClaimValidationFailed: typ is rt+jwt, the profile demands at+jwt
|
|
405
|
+
await jwtVerify(refresh, apiProfile);
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
---
|
|
409
|
+
|
|
410
|
+
## 9. Revocation
|
|
411
|
+
|
|
412
|
+
Every token gets a `jti`, so revocation always has a key. The in-memory
|
|
413
|
+
store suits a single process; implement the interface for anything else.
|
|
414
|
+
|
|
415
|
+
```ts
|
|
416
|
+
import { MemoryRevocationStore, defineProfile } from "lacewing";
|
|
417
|
+
|
|
418
|
+
const store = new MemoryRevocationStore();
|
|
419
|
+
|
|
420
|
+
const profile = defineProfile({
|
|
421
|
+
typ: "at+jwt",
|
|
422
|
+
issuer: ISSUER,
|
|
423
|
+
audience: AUDIENCE,
|
|
424
|
+
algorithms: ["EdDSA"],
|
|
425
|
+
keys: publicKey,
|
|
426
|
+
maxTokenAge: "15m",
|
|
427
|
+
revocation: store,
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// On logout. jti and exp both come off the verified payload.
|
|
431
|
+
const { payload } = await jwtVerify(token, profile);
|
|
432
|
+
store.revoke(payload.jti as string, payload.exp as number);
|
|
433
|
+
|
|
434
|
+
await jwtVerify(token, profile); // now throws JWTRevoked
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
Entries are dropped once the token would have expired anyway, so the store
|
|
438
|
+
does not grow without bound.
|
|
439
|
+
|
|
440
|
+
For a deployment, back it with Redis or your database:
|
|
441
|
+
|
|
442
|
+
```ts
|
|
443
|
+
import type { RevocationStore, TokenRevocationContext } from "lacewing";
|
|
444
|
+
|
|
445
|
+
class RedisRevocationStore implements RevocationStore {
|
|
446
|
+
constructor(private redis: Redis) {}
|
|
447
|
+
|
|
448
|
+
async isRevoked(ctx: TokenRevocationContext): Promise<boolean> {
|
|
449
|
+
if (ctx.jti === undefined) return false;
|
|
450
|
+
return (await this.redis.exists(`revoked:${ctx.jti}`)) === 1;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
The context also carries `sub`, `sid`, `exp` and `iat`, so "revoke every
|
|
456
|
+
session for this user" needs no interface change.
|
|
457
|
+
|
|
458
|
+
A store that throws fails closed: the token is rejected because you cannot
|
|
459
|
+
confirm it is not revoked. A Redis outage is therefore an auth outage. The
|
|
460
|
+
opposite trade exists and is named to be conspicuous:
|
|
461
|
+
|
|
462
|
+
```ts
|
|
463
|
+
unsafeFailOpenOnRevocationError: true
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
---
|
|
467
|
+
|
|
468
|
+
## 10. Distributing keys in production
|
|
469
|
+
|
|
470
|
+
Your API servers need the public key. In production that means a JWKS
|
|
471
|
+
endpoint rather than shipping a key file to every service:
|
|
472
|
+
|
|
473
|
+
```ts
|
|
474
|
+
const profile = defineProfile({
|
|
475
|
+
typ: "at+jwt",
|
|
476
|
+
issuer: "https://auth.example.com",
|
|
477
|
+
audience: "https://api.example.com",
|
|
478
|
+
algorithms: ["EdDSA"],
|
|
479
|
+
keys: { jwksUri: "https://auth.example.com/.well-known/jwks.json" },
|
|
480
|
+
maxTokenAge: "15m",
|
|
481
|
+
});
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
Caching, `Cache-Control` handling, a fetch cooldown, a size cap and a
|
|
485
|
+
timeout apply by default. HTTPS is mandatory, redirects are never followed,
|
|
486
|
+
and symmetric keys are refused: a JWKS endpoint is public, so an `oct` key
|
|
487
|
+
published there is one any reader could mint with.
|
|
488
|
+
|
|
489
|
+
The knobs and their defaults:
|
|
490
|
+
|
|
491
|
+
```ts
|
|
492
|
+
keys: {
|
|
493
|
+
jwksUri: "https://auth.example.com/.well-known/jwks.json",
|
|
494
|
+
cacheTtlSeconds: 300,
|
|
495
|
+
cooldownSeconds: 30,
|
|
496
|
+
timeoutMs: 5000,
|
|
497
|
+
staleWhileErrorSeconds: 3600, // how long known-good keys serve during an outage
|
|
498
|
+
}
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
`staleWhileErrorSeconds` is bounded deliberately. A key rotated out because
|
|
502
|
+
it was compromised must not stay trusted for as long as an attacker can keep
|
|
503
|
+
your JWKS endpoint unreachable. Set it to `0` to disable stale serving.
|
|
504
|
+
|
|
505
|
+
Publishing the JWKS from the signing side:
|
|
506
|
+
|
|
507
|
+
```ts
|
|
508
|
+
import { exportKeyJWK } from "lacewing";
|
|
509
|
+
|
|
510
|
+
const jwks = { keys: [{ ...(await exportKeyJWK(publicKey)), kid: "2026-08" }] };
|
|
511
|
+
app.get("/.well-known/jwks.json", (_req, res) => res.json(jwks));
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
### Before you plan a rotation
|
|
515
|
+
|
|
516
|
+
`SignJWT` emits only `{ alg, typ }` in the header and cannot set a `kid`.
|
|
517
|
+
JWKS key selection matches on key type, curve and `kid`, so two keys of the
|
|
518
|
+
same algorithm in one JWKS are ambiguous for a Lacewing-signed token:
|
|
519
|
+
|
|
520
|
+
```ts
|
|
521
|
+
// A JWKS holding two EdDSA keys, the state you are in during a rotation.
|
|
522
|
+
await jwtVerify(token, profile);
|
|
523
|
+
// throws JWKSNoMatchingKey: Multiple keys in the JWKS match this token -
|
|
524
|
+
// issue tokens with a kid
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
Since you cannot issue with a `kid`, plan around it. In order of preference:
|
|
528
|
+
|
|
529
|
+
1. Verify against an explicit key rather than a JWKS wherever you control
|
|
530
|
+
both ends. `keys: publicKey` has no ambiguity to resolve.
|
|
531
|
+
2. Rotate across algorithms. Publish the outgoing `EdDSA` key and the
|
|
532
|
+
incoming `ES256` key together, allow both during the overlap, then drop
|
|
533
|
+
the old one. Tokens then select by `alg`:
|
|
534
|
+
|
|
535
|
+
```ts
|
|
536
|
+
const profile = defineProfile({
|
|
537
|
+
typ: "at+jwt", issuer: ISSUER, audience: AUDIENCE,
|
|
538
|
+
algorithms: ["EdDSA", "ES256"], // both, only during the overlap
|
|
539
|
+
keys: { jwksUri: "https://auth.example.com/.well-known/jwks.json" },
|
|
540
|
+
maxTokenAge: "15m",
|
|
541
|
+
});
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
3. Hard cutover. Publish one key at a time and accept that tokens signed
|
|
545
|
+
with the old key fail for one token lifetime. At 10-minute access tokens
|
|
546
|
+
that window is small.
|
|
547
|
+
|
|
548
|
+
Verifying other issuers' tokens is unaffected. Lacewing reads and validates
|
|
549
|
+
`kid` normally, so a JWKS from Auth0, Okta or Cognito works as expected. The
|
|
550
|
+
constraint applies only to tokens Lacewing signs.
|
|
551
|
+
|
|
552
|
+
---
|
|
553
|
+
|
|
554
|
+
## 11. Handling errors
|
|
555
|
+
|
|
556
|
+
Every error extends `JWTError` and carries a machine-readable `code`.
|
|
557
|
+
Messages are generic and never echo token content, so they cannot become an
|
|
558
|
+
oracle or a log-injection vector.
|
|
559
|
+
|
|
560
|
+
```ts
|
|
561
|
+
import { JWTError, JWTExpired, JWTRevoked } from "lacewing";
|
|
562
|
+
|
|
563
|
+
try {
|
|
564
|
+
const { payload } = await jwtVerify(token, profile);
|
|
565
|
+
return handle(payload);
|
|
566
|
+
} catch (error) {
|
|
567
|
+
if (error instanceof JWTExpired) return res.status(401).json({ error: "token_expired" });
|
|
568
|
+
if (error instanceof JWTRevoked) return res.status(401).json({ error: "revoked" });
|
|
569
|
+
if (error instanceof JWTError) {
|
|
570
|
+
logger.warn({ code: error.code }, "token rejected");
|
|
571
|
+
return res.status(401).json({ error: "invalid_token" });
|
|
572
|
+
}
|
|
573
|
+
throw error; // not a token problem; don't swallow it
|
|
574
|
+
}
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
The codes you will branch on:
|
|
578
|
+
|
|
579
|
+
| Error | `code` | Means |
|
|
580
|
+
|---|---|---|
|
|
581
|
+
| `JWTInvalid` | `JWT_INVALID` | Malformed, or the signature doesn't check out |
|
|
582
|
+
| `JWTExpired` | `JWT_EXPIRED` | Past `exp`, or older than `maxTokenAge` |
|
|
583
|
+
| `JWTClaimValidationFailed` | `JWT_CLAIM_VALIDATION_FAILED` | A claim is present but wrong (`.claim` names it) |
|
|
584
|
+
| `MissingClaim` | `MISSING_CLAIM` | A required claim isn't there |
|
|
585
|
+
| `AlgorithmNotAllowed` | `ALGORITHM_NOT_ALLOWED` | `alg` isn't in your allowlist |
|
|
586
|
+
| `JWTRevoked` | `JWT_REVOKED` | The store says no |
|
|
587
|
+
| `RevocationCheckFailed` | `REVOCATION_CHECK_FAILED` | The store broke; failing closed |
|
|
588
|
+
| `JWKSFetchFailed` | `JWKS_FETCH_FAILED` | Couldn't fetch the JWKS |
|
|
589
|
+
| `JWKSNoMatchingKey` | `JWKS_NO_MATCHING_KEY` | No key matched, or several did |
|
|
590
|
+
| `EntropyCheckFailed` | `ENTROPY_CHECK_FAILED` | HMAC secret is too weak; a startup problem |
|
|
591
|
+
| `PayloadHygieneViolation` | `PAYLOAD_HYGIENE_VIOLATION` | You're about to sign a secret |
|
|
592
|
+
|
|
593
|
+
Do not return a distinct HTTP response per code to unauthenticated callers.
|
|
594
|
+
Use `401 invalid_token` for all of them and keep the detail in logs.
|
|
595
|
+
|
|
596
|
+
---
|
|
597
|
+
|
|
598
|
+
## 12. Coming from `jsonwebtoken`
|
|
599
|
+
|
|
600
|
+
The mapping is mechanical except for two habits.
|
|
601
|
+
|
|
602
|
+
| `jsonwebtoken` | Lacewing |
|
|
603
|
+
|---|---|
|
|
604
|
+
| `jwt.sign(payload, key, opts)` | `new SignJWT(typ).issuer().audience().expiresIn().sign(key)` |
|
|
605
|
+
| `jwt.verify(token, key, opts)` | `jwtVerify(token, profile)` |
|
|
606
|
+
| `jwt.decode(token)` | `unsafeDecode(token)`, branded untrusted |
|
|
607
|
+
| options per call site | one `defineProfile` shared by every call site |
|
|
608
|
+
| `algorithms: [...]` optional | mandatory, always |
|
|
609
|
+
|
|
610
|
+
Options move to the profile. Passing verification options at each call site
|
|
611
|
+
is how one endpoint ends up not checking `audience`. Define the profile once
|
|
612
|
+
and import it everywhere.
|
|
613
|
+
|
|
614
|
+
`typ` is mandatory. `jsonwebtoken` does not make you distinguish token
|
|
615
|
+
kinds, so most codebases have one sort of JWT doing several jobs. Lacewing
|
|
616
|
+
requires the kind to be named, and different kinds refuse each other.
|
|
617
|
+
Existing tokens without a `typ` have to be reissued; no profile accepts an
|
|
618
|
+
absent `typ`.
|
|
619
|
+
|
|
620
|
+
Migration order:
|
|
621
|
+
|
|
622
|
+
1. Add Lacewing alongside your existing library; issue new tokens with both.
|
|
623
|
+
2. Move verification to `jwtVerify` with a profile, behind a flag.
|
|
624
|
+
3. Once all live tokens carry `typ`, drop the old path.
|
|
625
|
+
|
|
626
|
+
Step 3 waits one token lifetime.
|
|
627
|
+
|
|
628
|
+
---
|
|
629
|
+
|
|
630
|
+
## 13. Adoption checklist
|
|
631
|
+
|
|
632
|
+
- [ ] `"type": "module"` and Node 24+ in your deploy image
|
|
633
|
+
- [ ] Asymmetric keys (`EdDSA`) unless you deliberately chose otherwise
|
|
634
|
+
- [ ] Private key loaded from your secret manager, never from the repo
|
|
635
|
+
- [ ] One `defineProfile` per token kind, at module scope, exported
|
|
636
|
+
- [ ] `audience` is the specific service, not a wildcard
|
|
637
|
+
- [ ] `maxTokenAge` set to what you accept, not what the issuer claims
|
|
638
|
+
- [ ] Access tokens in minutes; refresh tokens revocable
|
|
639
|
+
- [ ] Browser tokens in `HttpOnly` cookies; grep for `localStorage` to be sure
|
|
640
|
+
- [ ] Node framework? `new Headers(req.headers)` at the edge
|
|
641
|
+
- [ ] A revocation store that survives a restart
|
|
642
|
+
- [ ] A decision on what a store outage should do; the default fails closed
|
|
643
|
+
- [ ] A rotation plan that accounts for the `kid` constraint in
|
|
644
|
+
[§10](#10-distributing-keys-in-production)
|
|
645
|
+
- [ ] `401 invalid_token` for every rejection; codes go to logs only
|
|
646
|
+
- [ ] CI greps for `unsafeAllow`, `unsafeDecode` and
|
|
647
|
+
`unsafeFailOpenOnRevocationError`
|
|
648
|
+
|
|
649
|
+
---
|
|
650
|
+
|
|
651
|
+
`npm run demo` in the repository runs a live walkthrough where every
|
|
652
|
+
rejection is a real thrown error. For the full API, see the
|
|
653
|
+
[README](./README.md) and the generated
|
|
654
|
+
[TypeDoc](https://github.com/grMLEqomlkkU5Eeinz4brIrOVCUCkJuN/lacewing).
|