@sdxc/jwt 2026.10.9 → 2026.10.13
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/README.md +225 -95
- package/dist/jwk.js +2 -1
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -2,30 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
JWT payload classes and the ES256, RS256 and EdDSA keys that sign them.
|
|
4
4
|
|
|
5
|
+
Each kind of token is a class: the registered claims arrive as typed accessors, a subclass adds
|
|
6
|
+
its own, and signing, verifying and decoding all hand back an instance of that class. The `JWK`
|
|
7
|
+
namespace covers a key's whole life: generating, storing, importing, publishing as a JWKS and
|
|
8
|
+
resolving someone else's JWKS for verification.
|
|
9
|
+
|
|
5
10
|
## Installation
|
|
6
11
|
|
|
7
|
-
```
|
|
12
|
+
```sh
|
|
8
13
|
npm add @sdxc/jwt
|
|
9
14
|
```
|
|
10
15
|
|
|
11
|
-
Signing and verification run on [`jose`](https://www.npmjs.com/package/jose), and
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
- `@sdxc/jwt/r2` — `createR2KeyStorage`, the `KeyStorage` over a Cloudflare R2 bucket.
|
|
16
|
+
Signing and verification run on [`jose`](https://www.npmjs.com/package/jose), and duration strings
|
|
17
|
+
for the time claims come from [`@sdxc/duration`](https://www.npmjs.com/package/@sdxc/duration);
|
|
18
|
+
both install with the package. Three entry points: `@sdxc/jwt` (`JWT`, `JWK`, `KeyStorage`),
|
|
19
|
+
`@sdxc/jwt/algorithm` (the algorithm names alone) and `@sdxc/jwt/r2` (key storage over a
|
|
20
|
+
Cloudflare R2 bucket).
|
|
17
21
|
|
|
18
22
|
## Usage
|
|
19
23
|
|
|
20
|
-
###
|
|
24
|
+
### Describe a kind of token
|
|
21
25
|
|
|
22
|
-
Subclass `JWT` and
|
|
26
|
+
Subclass `JWT` and read each claim the token carries through `this.parser`, which type-checks it.
|
|
23
27
|
|
|
24
28
|
```typescript
|
|
25
29
|
import { JWT } from "@sdxc/jwt";
|
|
26
30
|
|
|
27
31
|
export class IdToken extends JWT {
|
|
28
|
-
// Narrowed: this token always carries `sub`, so the accessor is `string`.
|
|
29
32
|
override get subject() {
|
|
30
33
|
return this.parser.string("sub");
|
|
31
34
|
}
|
|
@@ -36,17 +39,25 @@ export class IdToken extends JWT {
|
|
|
36
39
|
}
|
|
37
40
|
```
|
|
38
41
|
|
|
39
|
-
###
|
|
42
|
+
### Issue a token
|
|
43
|
+
|
|
44
|
+
`JWK.signingKeys` loads the keys from storage, minting an ES256 key the first time. Time claims
|
|
45
|
+
accept a duration measured from now.
|
|
40
46
|
|
|
41
47
|
```typescript
|
|
42
|
-
import { JWK
|
|
48
|
+
import { JWK } from "@sdxc/jwt";
|
|
49
|
+
import { createR2KeyStorage } from "@sdxc/jwt/r2";
|
|
50
|
+
import { env } from "cloudflare:workers";
|
|
43
51
|
|
|
44
|
-
|
|
52
|
+
import { IdToken } from "./id-token.js";
|
|
45
53
|
|
|
46
|
-
let
|
|
54
|
+
let keys = await JWK.signingKeys(createR2KeyStorage(env.KEYS));
|
|
55
|
+
|
|
56
|
+
let token = new IdToken({
|
|
47
57
|
iss: "https://auth.example.com",
|
|
48
|
-
sub: user
|
|
49
|
-
aud: client
|
|
58
|
+
sub: "user-123",
|
|
59
|
+
aud: "client-abc",
|
|
60
|
+
email: "ada@example.com",
|
|
50
61
|
iat: "0s",
|
|
51
62
|
exp: "1h",
|
|
52
63
|
});
|
|
@@ -54,19 +65,30 @@ let token = new JWT({
|
|
|
54
65
|
let signed = await token.sign(JWK.Algorithm.ES256, keys);
|
|
55
66
|
```
|
|
56
67
|
|
|
57
|
-
###
|
|
68
|
+
### Publish the key set
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
let jwks = JWK.toJSON(keys);
|
|
72
|
+
|
|
73
|
+
return Response.json(jwks); // served at /.well-known/jwks.json
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Verify a token
|
|
77
|
+
|
|
78
|
+
Point a resolver at the issuer's JWKS once and keep it; `verify` answers an instance of the class
|
|
79
|
+
it is called on.
|
|
58
80
|
|
|
59
81
|
```typescript
|
|
60
82
|
import { JWK } from "@sdxc/jwt";
|
|
61
83
|
|
|
62
|
-
|
|
84
|
+
import { IdToken } from "./id-token.js";
|
|
85
|
+
|
|
63
86
|
let keys = await JWK.importRemote(new URL("https://auth.example.com/.well-known/jwks.json"));
|
|
64
87
|
|
|
65
88
|
let idToken = await IdToken.verify(rawToken, keys, {
|
|
66
89
|
issuer: "https://auth.example.com",
|
|
67
|
-
audience:
|
|
90
|
+
audience: "client-abc",
|
|
68
91
|
algorithms: [JWK.Algorithm.ES256],
|
|
69
|
-
clockTolerance: 60,
|
|
70
92
|
});
|
|
71
93
|
|
|
72
94
|
idToken.subject; // "user-123"
|
|
@@ -77,31 +99,27 @@ idToken.email; // "ada@example.com"
|
|
|
77
99
|
|
|
78
100
|
### `JWT`
|
|
79
101
|
|
|
80
|
-
A
|
|
102
|
+
A claim set with the registered claims as accessors. Use it directly, or subclass it per kind of
|
|
103
|
+
token.
|
|
81
104
|
|
|
82
105
|
#### `new JWT(payload?: JWT.PayloadInput)`
|
|
83
106
|
|
|
84
|
-
Wraps a claim set,
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
Instances are proxied: a claim with no accessor still reads by name and answers `null` when absent, and assigning an unknown property writes it into the payload, so it is carried into the signature.
|
|
89
|
-
|
|
90
|
-
```typescript
|
|
91
|
-
let jwt = new JWT({ sub: "user-123", scope: "read", exp: "1h" });
|
|
92
|
-
jwt.scope; // "read" — no accessor declared, read through the payload
|
|
93
|
-
jwt.missing; // null
|
|
94
|
-
```
|
|
107
|
+
Wraps a claim set, `{}` by default. `exp`, `iat` and `nbf` take seconds since the epoch or a
|
|
108
|
+
duration string (`"1h"`) measured from construction; `payload` holds the resolved seconds. A
|
|
109
|
+
claim with no accessor still reads by name (`null` when absent), and assigning an unknown property
|
|
110
|
+
writes it into the payload.
|
|
95
111
|
|
|
96
112
|
#### `jwt.payload`
|
|
97
113
|
|
|
98
|
-
The
|
|
114
|
+
The resolved claim set the accessors read and `sign` signs.
|
|
99
115
|
|
|
100
116
|
#### `jwt.parser`
|
|
101
117
|
|
|
102
|
-
Protected
|
|
118
|
+
Protected, for subclass accessors: `has(key)`, `get(key)`, `string(key)`, `number(key)`,
|
|
119
|
+
`boolean(key)` and `object(key)` (a parser over a nested object). Every read but `has` throws when
|
|
120
|
+
the claim is absent or holds another JSON type, so guard an optional claim with `has`.
|
|
103
121
|
|
|
104
|
-
#### Registered
|
|
122
|
+
#### Registered claim accessors
|
|
105
123
|
|
|
106
124
|
| Accessor | Claim | Type | Writable |
|
|
107
125
|
| ---------------- | ----- | ---------------------------- | -------- |
|
|
@@ -116,43 +134,37 @@ Protected. Type-checked reads over `payload` for the accessors a subclass define
|
|
|
116
134
|
| `notBefore` | `nbf` | `Date \| null` | yes |
|
|
117
135
|
| `subject` | `sub` | `string \| null` | yes |
|
|
118
136
|
|
|
119
|
-
|
|
137
|
+
Absent claims read `null`, and setting `null` drops the claim. `expiresIn` is the seconds left
|
|
138
|
+
(negative once past), and `expired` is `true` once it reaches zero; `verify` is what enforces
|
|
139
|
+
expiry.
|
|
120
140
|
|
|
121
141
|
#### `jwt.sign(algorithm: JWK.Algorithm, jwks: JWK.SigningKey[]): Promise<string>`
|
|
122
142
|
|
|
123
|
-
Signs
|
|
143
|
+
Signs with the first key generated for `algorithm`, writes its id as the header `kid`, and answers
|
|
144
|
+
the compact token. Throws when no key matches. `JWT.sign(jwt, algorithm, jwks)` is the static form.
|
|
124
145
|
|
|
125
146
|
#### `jwt.toJSON(): Record<string, unknown>`
|
|
126
147
|
|
|
127
|
-
The
|
|
148
|
+
The getters declared on the instance's own class, as a plain object.
|
|
128
149
|
|
|
129
150
|
#### `JWT.verify(token: string, jwks: JWK.VerificationKeys, options?: JWT.VerifyOptions): Promise<JWT>`
|
|
130
151
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
152
|
+
Checks the signature, `exp`, `nbf` and any `issuer`/`audience` given, and answers an instance of
|
|
153
|
+
the calling class; any failure throws. The key is picked per token by its `kid`, key type and
|
|
154
|
+
algorithm, so a set can hold several keys. Pass `algorithms` to accept only the ones you expect.
|
|
134
155
|
|
|
135
156
|
#### `JWT.decode(token: string): JWT`
|
|
136
157
|
|
|
137
|
-
Reads
|
|
138
|
-
|
|
139
|
-
#### Types
|
|
140
|
-
|
|
141
|
-
`JWT.Payload` is `jose.JWTPayload`, with every time claim as epoch seconds. `JWT.PayloadInput` is the same claim set with `exp`, `iat`, and `nbf` also accepting a duration string. `JWT.VerifyOptions` is `jose.JWTVerifyOptions`.
|
|
158
|
+
Reads the claims without verifying them, as an instance of the calling class. Use it to decide how
|
|
159
|
+
to verify (reading `iss` to pick a key set), then `verify`.
|
|
142
160
|
|
|
143
161
|
### `JWK`
|
|
144
162
|
|
|
145
163
|
#### `JWK.Algorithm`
|
|
146
164
|
|
|
147
|
-
The supported
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
| --------- | ----- | ---------------------------------------------------- |
|
|
151
|
-
| `ES256` | `EC` | Signing; it is what a bootstrapped key is minted for |
|
|
152
|
-
| `RS256` | `RSA` | Verifying an upstream identity provider's tokens |
|
|
153
|
-
| `EdDSA` | `OKP` | Signing, where a deterministic nonce is wanted |
|
|
154
|
-
|
|
155
|
-
All three coexist in one key set, since verification picks a key by the `kid` and algorithm a token names. The names are also importable on their own, for a caller that reads or lists them without the key machinery behind them:
|
|
165
|
+
The supported algorithms: `JWK.Algorithm.ES256` (`EC`, what `signingKeys` mints), `.RS256`
|
|
166
|
+
(`RSA`, common among upstream identity providers) and `.EdDSA` (`OKP`, deterministic nonces). The
|
|
167
|
+
names also ship alone from `@sdxc/jwt/algorithm`:
|
|
156
168
|
|
|
157
169
|
```typescript
|
|
158
170
|
import * as Algorithm from "@sdxc/jwt/algorithm";
|
|
@@ -162,40 +174,49 @@ Object.values(Algorithm); // ["ES256", "RS256", "EdDSA"]
|
|
|
162
174
|
|
|
163
175
|
#### `JWK.generateKeyPair(alg: JWK.Algorithm): Promise<JWK.SerializedKeyPair>`
|
|
164
176
|
|
|
165
|
-
Generates
|
|
177
|
+
Generates an extractable key pair as PEM strings, a UUID `id` and a millisecond timestamp, ready
|
|
178
|
+
for `JSON.stringify` into a bucket or columns.
|
|
166
179
|
|
|
167
180
|
#### `JWK.importKeyPair(value: JWK.SerializedKeyPair): Promise<JWK.KeyPair>`
|
|
168
181
|
|
|
169
|
-
Imports a stored pair
|
|
182
|
+
Imports a stored pair into `CryptoKey` objects, attaching the public half as a JWK with `kid` and
|
|
183
|
+
`use: "sig"`. The private key stays non-extractable. Each call imports two keys, so cache the
|
|
184
|
+
result.
|
|
170
185
|
|
|
171
186
|
#### `JWK.signingKeys(storage: KeyStorage): Promise<JWK.KeyPair[]>`
|
|
172
187
|
|
|
173
|
-
Loads every
|
|
188
|
+
Loads every key stored under `signing:key:<id>`, newest first, which is the order `sign` picks
|
|
189
|
+
from. When none is unexpired, it mints and stores an ES256 key first.
|
|
174
190
|
|
|
175
191
|
#### `JWK.importLocal(jwks: jose.JSONWebKeySet): Promise<JWK.KeyResolver>`
|
|
176
192
|
|
|
177
|
-
|
|
193
|
+
A resolver over a key set already in hand.
|
|
178
194
|
|
|
179
195
|
#### `JWK.importRemote(url: URL, options?: JWK.RemoteOptions): Promise<JWK.KeyResolver>`
|
|
180
196
|
|
|
181
|
-
|
|
197
|
+
A resolver over a JWKS endpoint. It fetches on first use, holds the result, and refetches (at most
|
|
198
|
+
once per cooldown) when a token names an unknown `kid`, which carries verifiers through a key
|
|
199
|
+
rotation. Create it once per process.
|
|
182
200
|
|
|
183
|
-
#### `JWK.toJSON(keys: JWK.KeyPair[]):
|
|
201
|
+
#### `JWK.toJSON(keys: JWK.KeyPair[]): JWK.PublishedKeySet`
|
|
184
202
|
|
|
185
|
-
|
|
203
|
+
The JWKS document for `/.well-known/jwks.json`: each entry carries `kty`, `kid`, `alg` and only the
|
|
204
|
+
public parameters (`crv`/`x`/`y` for `EC`, `crv`/`x` for `OKP`, `e`/`n` for `RSA`). Throws on any
|
|
205
|
+
other key type.
|
|
186
206
|
|
|
187
207
|
#### Types
|
|
188
208
|
|
|
189
209
|
```typescript
|
|
190
210
|
interface KeyPair {
|
|
191
|
-
id: string; //
|
|
211
|
+
id: string; // the `kid`
|
|
192
212
|
alg: Algorithm;
|
|
193
213
|
public: jose.CryptoKey;
|
|
194
214
|
private: jose.CryptoKey;
|
|
195
215
|
created: Date;
|
|
196
|
-
expired?: Date; // set once
|
|
216
|
+
expired?: Date; // set once retired from signing
|
|
197
217
|
jwk: jose.JWK; // public half, ready to publish
|
|
198
218
|
}
|
|
219
|
+
|
|
199
220
|
interface SerializedKeyPair {
|
|
200
221
|
id: `${string}-${string}-${string}-${string}-${string}`;
|
|
201
222
|
alg: Algorithm;
|
|
@@ -204,21 +225,27 @@ interface SerializedKeyPair {
|
|
|
204
225
|
created: number; // epoch milliseconds
|
|
205
226
|
expired?: Date;
|
|
206
227
|
}
|
|
228
|
+
|
|
207
229
|
interface SigningKey {
|
|
208
230
|
id: string;
|
|
209
231
|
alg: string;
|
|
210
232
|
private: jose.CryptoKey;
|
|
211
233
|
}
|
|
234
|
+
|
|
212
235
|
interface VerificationKey {
|
|
213
236
|
jwk: jose.JWK;
|
|
214
|
-
}
|
|
237
|
+
}
|
|
215
238
|
```
|
|
216
239
|
|
|
217
|
-
|
|
240
|
+
A `KeyPair` satisfies both `SigningKey` and `VerificationKey`. `JWK.KeyResolver` is
|
|
241
|
+
`jose.JWTVerifyGetKey`; `JWK.VerificationKeys` is `KeyResolver | VerificationKey[]`;
|
|
242
|
+
`JWK.RemoteOptions` is `jose.RemoteJWKSetOptions`. `JWK.PublishedKeySet` is `{ keys:
|
|
243
|
+
PublishedKey[] }`, where `JWK.PublishedKey` is a JWKS entry with a required `kty`.
|
|
218
244
|
|
|
219
245
|
### `KeyStorage`
|
|
220
246
|
|
|
221
|
-
The
|
|
247
|
+
The store `JWK.signingKeys` reads and writes: `File` values under string keys, listed by prefix.
|
|
248
|
+
Each method may be synchronous or return a promise.
|
|
222
249
|
|
|
223
250
|
```typescript
|
|
224
251
|
interface KeyStorage {
|
|
@@ -232,72 +259,147 @@ interface KeyStorageListOptions {
|
|
|
232
259
|
limit?: number;
|
|
233
260
|
prefix?: string;
|
|
234
261
|
}
|
|
262
|
+
|
|
235
263
|
interface KeyStorageListResult {
|
|
236
|
-
cursor?: string;
|
|
264
|
+
cursor?: string; // present only while more pages remain
|
|
237
265
|
files: { key: string }[];
|
|
238
266
|
}
|
|
239
267
|
```
|
|
240
268
|
|
|
241
|
-
|
|
269
|
+
`get` answers `null` for an unknown key, `list` pages entries whose key starts with `prefix`, and
|
|
270
|
+
`set` replaces what is stored. Keep every key ever written so every token it signed stays
|
|
271
|
+
verifiable.
|
|
242
272
|
|
|
243
273
|
### `createR2KeyStorage(bucket: R2KeyBucket): KeyStorage`
|
|
244
274
|
|
|
245
|
-
From `@sdxc/jwt/r2`.
|
|
275
|
+
From `@sdxc/jwt/r2`. A `KeyStorage` over a Cloudflare R2 bucket, so every Worker issuing for one
|
|
276
|
+
issuer shares the same keys. Files read back with the name, type and upload time they were stored
|
|
277
|
+
with. `R2KeyBucket` is the `get`/`list`/`put` part of a bucket binding, which an `R2Bucket`
|
|
278
|
+
satisfies; `R2KeyObject` is what its `get` answers.
|
|
279
|
+
|
|
280
|
+
## Pattern: An Issuer In A Worker
|
|
281
|
+
|
|
282
|
+
Sign tokens and serve the matching JWKS from the same R2-backed key set.
|
|
246
283
|
|
|
247
284
|
```typescript
|
|
248
|
-
import { JWK } from "@sdxc/jwt";
|
|
285
|
+
import { JWK, JWT } from "@sdxc/jwt";
|
|
249
286
|
import { createR2KeyStorage } from "@sdxc/jwt/r2";
|
|
287
|
+
import { generateUUID } from "@sdxc/uuid/v4";
|
|
250
288
|
import { env } from "cloudflare:workers";
|
|
251
289
|
|
|
252
|
-
let
|
|
253
|
-
```
|
|
254
|
-
|
|
255
|
-
Each object is written with the file's type as its `Content-Type` and its name and type as custom metadata (`name`, `type`), so `get` hands back the `File` that was stored, its `lastModified` set to the upload time. An object written without that metadata reads back named after its key. `list` carries R2's cursor only while a page is truncated.
|
|
256
|
-
|
|
257
|
-
`R2KeyBucket` is the part of a bucket binding the storage calls — `get`, `list` and `put` — declared structurally, so an `R2Bucket` satisfies it and importing the module needs no platform types. `R2KeyObject` is what its `get` answers.
|
|
290
|
+
let ISSUER = "https://auth.example.com";
|
|
258
291
|
|
|
259
|
-
|
|
292
|
+
export default {
|
|
293
|
+
async fetch(request: Request) {
|
|
294
|
+
let url = new URL(request.url);
|
|
295
|
+
let keys = await JWK.signingKeys(createR2KeyStorage(env.KEYS));
|
|
260
296
|
|
|
261
|
-
|
|
297
|
+
if (url.pathname === "/.well-known/jwks.json") {
|
|
298
|
+
return Response.json(JWK.toJSON(keys));
|
|
299
|
+
}
|
|
262
300
|
|
|
263
|
-
|
|
264
|
-
|
|
301
|
+
let token = new JWT({ iss: ISSUER, sub: "user-123", aud: "api", iat: "0s", exp: "15m" });
|
|
302
|
+
token.id = generateUUID();
|
|
265
303
|
|
|
266
|
-
|
|
304
|
+
return Response.json({ access_token: await token.sign(JWK.Algorithm.ES256, keys) });
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
```
|
|
267
308
|
|
|
268
309
|
## Pattern: Verifying An Upstream ID Token
|
|
269
310
|
|
|
270
|
-
|
|
311
|
+
Hold one resolver per provider, pin `algorithms` to what the provider's discovery document lists,
|
|
312
|
+
and treat every failure as "not authenticated". Subclass getters throw on a missing or mistyped
|
|
313
|
+
claim, so read them inside the same `try`.
|
|
271
314
|
|
|
272
315
|
```typescript
|
|
273
316
|
import { JWK, JWT } from "@sdxc/jwt";
|
|
274
317
|
|
|
275
|
-
|
|
318
|
+
class ProviderIdToken extends JWT {
|
|
319
|
+
get email() {
|
|
320
|
+
return this.parser.string("email");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
get emailVerified() {
|
|
324
|
+
return this.parser.has("email_verified") && this.parser.boolean("email_verified");
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
let KEYS = JWK.importRemote(new URL("https://accounts.example.com/.well-known/jwks.json"));
|
|
276
329
|
|
|
277
330
|
export async function verifyIdToken(raw: string, clientId: string) {
|
|
278
331
|
try {
|
|
279
|
-
|
|
332
|
+
let token = await ProviderIdToken.verify(raw, await KEYS, {
|
|
280
333
|
issuer: "https://accounts.example.com",
|
|
281
334
|
audience: clientId,
|
|
282
335
|
algorithms: [JWK.Algorithm.RS256],
|
|
283
336
|
clockTolerance: 60,
|
|
284
337
|
});
|
|
338
|
+
if (!token.emailVerified) return null;
|
|
339
|
+
return { subject: token.subject, email: token.email };
|
|
285
340
|
} catch {
|
|
286
341
|
return null;
|
|
287
342
|
}
|
|
288
343
|
}
|
|
289
344
|
```
|
|
290
345
|
|
|
291
|
-
|
|
346
|
+
## Pattern: Several Issuers, One Verifier
|
|
347
|
+
|
|
348
|
+
`decode` reads `iss` before anything is trusted, to pick which key set verifies the token.
|
|
349
|
+
|
|
350
|
+
```typescript
|
|
351
|
+
import { JWK, JWT } from "@sdxc/jwt";
|
|
352
|
+
|
|
353
|
+
let ISSUERS = new Map([
|
|
354
|
+
[
|
|
355
|
+
"https://auth.example.com",
|
|
356
|
+
JWK.importRemote(new URL("https://auth.example.com/.well-known/jwks.json")),
|
|
357
|
+
],
|
|
358
|
+
["https://login.example.org", JWK.importRemote(new URL("https://login.example.org/keys"))],
|
|
359
|
+
]);
|
|
360
|
+
|
|
361
|
+
export async function verify(raw: string) {
|
|
362
|
+
let issuer = JWT.decode(raw).issuer;
|
|
363
|
+
let keys = issuer ? ISSUERS.get(issuer) : undefined;
|
|
364
|
+
if (!issuer || !keys) return null;
|
|
365
|
+
|
|
366
|
+
return JWT.verify(raw, await keys, { issuer, audience: "api" });
|
|
367
|
+
}
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
## Pattern: Rotating Signing Keys
|
|
371
|
+
|
|
372
|
+
A rotation adds a key. Store a new pair under `signing:key:<id>`; `signingKeys` returns it first,
|
|
373
|
+
so it signs from then on, while the older keys stay in the JWKS and keep verifying what they signed.
|
|
374
|
+
Keep a retired key stored for at least as long as its tokens live.
|
|
375
|
+
|
|
376
|
+
```typescript
|
|
377
|
+
import { JWK } from "@sdxc/jwt";
|
|
378
|
+
import { createR2KeyStorage } from "@sdxc/jwt/r2";
|
|
379
|
+
import { env } from "cloudflare:workers";
|
|
380
|
+
|
|
381
|
+
let storage = createR2KeyStorage(env.KEYS);
|
|
382
|
+
let pair = await JWK.generateKeyPair(JWK.Algorithm.ES256);
|
|
383
|
+
|
|
384
|
+
await storage.set(
|
|
385
|
+
`signing:key:${pair.id}`,
|
|
386
|
+
new File([JSON.stringify(pair)], "jwks.json", { type: "application/json" }),
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
let keys = await JWK.signingKeys(storage); // keys[0].id === pair.id
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
Deploy verifiers that resolve keys by `kid` before the issuer publishes a second key; a verifier
|
|
393
|
+
built on `JWK.importRemote` picks the new key up the first time a token names it.
|
|
292
394
|
|
|
293
|
-
## Pattern: Keys In A Database
|
|
395
|
+
## Pattern: Keys In A Database
|
|
294
396
|
|
|
295
|
-
|
|
397
|
+
When keys live in rows, `generateKeyPair` and `importKeyPair` are all you need; the imported
|
|
398
|
+
pairs work with `sign` and `JWK.toJSON` exactly like `signingKeys` output.
|
|
296
399
|
|
|
297
400
|
```typescript
|
|
298
401
|
import { JWK } from "@sdxc/jwt";
|
|
299
402
|
|
|
300
|
-
// Minting: a serialized pair is PEM strings and a timestamp, so it stores as plain columns.
|
|
301
403
|
let serialized = await JWK.generateKeyPair(JWK.Algorithm.ES256);
|
|
302
404
|
|
|
303
405
|
await db.insert("signing_keys", {
|
|
@@ -305,26 +407,54 @@ await db.insert("signing_keys", {
|
|
|
305
407
|
alg: serialized.alg,
|
|
306
408
|
public_key: serialized.publicKey,
|
|
307
409
|
private_key: serialized.privateKey,
|
|
308
|
-
created_at:
|
|
410
|
+
created_at: serialized.created,
|
|
309
411
|
});
|
|
310
412
|
|
|
311
|
-
// Signing and publishing: import every stored row back, newest first.
|
|
312
413
|
let rows = await db.select("signing_keys", { orderBy: "created_at desc" });
|
|
313
414
|
|
|
314
415
|
let keys = await Promise.all(
|
|
315
416
|
rows.map((row) =>
|
|
316
417
|
JWK.importKeyPair({
|
|
317
|
-
id: row.id as JWK.SerializedKeyPair["id"],
|
|
318
|
-
alg: row.alg,
|
|
418
|
+
id: row.id as JWK.SerializedKeyPair["id"],
|
|
419
|
+
alg: row.alg as JWK.Algorithm,
|
|
319
420
|
publicKey: row.public_key,
|
|
320
421
|
privateKey: row.private_key,
|
|
321
|
-
created:
|
|
422
|
+
created: row.created_at,
|
|
322
423
|
}),
|
|
323
424
|
),
|
|
324
425
|
);
|
|
325
426
|
```
|
|
326
427
|
|
|
327
|
-
|
|
428
|
+
## Pattern: In-Memory Key Storage For Tests
|
|
429
|
+
|
|
430
|
+
A `Map` satisfies `KeyStorage` synchronously, so a test signs and verifies with no bucket.
|
|
431
|
+
|
|
432
|
+
```typescript
|
|
433
|
+
import type { KeyStorage } from "@sdxc/jwt";
|
|
434
|
+
|
|
435
|
+
import { JWK, JWT } from "@sdxc/jwt";
|
|
436
|
+
import { expect, test } from "vitest";
|
|
437
|
+
|
|
438
|
+
function memoryStorage(): KeyStorage {
|
|
439
|
+
let files = new Map<string, File>();
|
|
440
|
+
return {
|
|
441
|
+
get: (key) => files.get(key) ?? null,
|
|
442
|
+
list: ({ prefix = "" } = {}) => ({
|
|
443
|
+
files: [...files.keys()].filter((key) => key.startsWith(prefix)).map((key) => ({ key })),
|
|
444
|
+
}),
|
|
445
|
+
set: (key, file) => void files.set(key, file),
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
test("a signed token verifies against the published set", async () => {
|
|
450
|
+
let keys = await JWK.signingKeys(memoryStorage());
|
|
451
|
+
let signed = await new JWT({ sub: "user-123", exp: "5m" }).sign(JWK.Algorithm.ES256, keys);
|
|
452
|
+
|
|
453
|
+
let verified = await JWT.verify(signed, await JWK.importLocal(JWK.toJSON(keys)));
|
|
454
|
+
|
|
455
|
+
expect(verified.subject).toBe("user-123");
|
|
456
|
+
});
|
|
457
|
+
```
|
|
328
458
|
|
|
329
459
|
## Versioning
|
|
330
460
|
|
package/dist/jwk.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
10
10
|
* @copyright Sergio Xalambrí 2026
|
|
11
11
|
*/
|
|
12
|
+
import { generateUUID } from "@sdxc/uuid/v4";
|
|
12
13
|
import * as jose from "jose";
|
|
13
14
|
import * as SignatureAlgorithm from "./algorithm.js";
|
|
14
15
|
/**
|
|
@@ -62,7 +63,7 @@ export var JWK;
|
|
|
62
63
|
async function generateKeyPair(alg) {
|
|
63
64
|
let key = await jose.generateKeyPair(alg, { extractable: true });
|
|
64
65
|
return {
|
|
65
|
-
id:
|
|
66
|
+
id: generateUUID(),
|
|
66
67
|
publicKey: await jose.exportSPKI(key.publicKey),
|
|
67
68
|
privateKey: await jose.exportPKCS8(key.privateKey),
|
|
68
69
|
created: Date.now(),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdxc/jwt",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.13",
|
|
4
4
|
"description": "JWT payload classes and the keys that sign them",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -10,10 +10,11 @@
|
|
|
10
10
|
"./r2": "./dist/r2-key-storage.js"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@sdxc/duration": "2026.10.
|
|
14
|
-
"
|
|
13
|
+
"@sdxc/duration": "2026.10.13",
|
|
14
|
+
"@sdxc/uuid": "2026.10.13",
|
|
15
|
+
"jose": "^6.2.12"
|
|
15
16
|
},
|
|
16
|
-
"gitHead": "
|
|
17
|
+
"gitHead": "5ff758a57e35480a8db7abd24f1d9527b81bfb51",
|
|
17
18
|
"publishConfig": {
|
|
18
19
|
"access": "public"
|
|
19
20
|
},
|