@sdxc/jwt 0.0.0-pre.1 → 2026.9.5
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 +111 -229
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -2,26 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
JWT payload classes and the ES256, RS256 and EdDSA keys that sign them.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Installation
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
`JWK` covers the other half: generating a key pair, serializing it so it survives in a bucket or a database row, importing it back into `CryptoKey` objects, publishing the public half as a JWKS, and turning someone else's JWKS — held locally or fetched — into the resolver a token is verified through.
|
|
7
|
+
```bash
|
|
8
|
+
npm add @sdxc/jwt
|
|
9
|
+
```
|
|
12
10
|
|
|
13
|
-
|
|
11
|
+
Signing and verification run on [`jose`](https://www.npmjs.com/package/jose), and the duration strings the time claims accept come from [`@sdxc/duration`](https://www.npmjs.com/package/@sdxc/duration). Both install with the package.
|
|
14
12
|
|
|
15
13
|
## Usage
|
|
16
14
|
|
|
17
15
|
### Describing A Kind Of Token
|
|
18
16
|
|
|
17
|
+
Subclass `JWT` and give each claim the token guarantees a typed accessor.
|
|
18
|
+
|
|
19
19
|
```typescript
|
|
20
20
|
import { JWT } from "@sdxc/jwt";
|
|
21
21
|
|
|
22
|
-
export
|
|
23
|
-
// Narrowed: this token always carries `sub`, so the accessor is `string
|
|
24
|
-
// `string | null`.
|
|
22
|
+
export class IdToken extends JWT {
|
|
23
|
+
// Narrowed: this token always carries `sub`, so the accessor is `string`.
|
|
25
24
|
override get subject() {
|
|
26
25
|
return this.parser.string("sub");
|
|
27
26
|
}
|
|
@@ -29,10 +28,6 @@ export default class IdToken extends JWT {
|
|
|
29
28
|
get email() {
|
|
30
29
|
return this.parser.string("email");
|
|
31
30
|
}
|
|
32
|
-
|
|
33
|
-
get emailVerified() {
|
|
34
|
-
return this.parser.boolean("email_verified");
|
|
35
|
-
}
|
|
36
31
|
}
|
|
37
32
|
```
|
|
38
33
|
|
|
@@ -59,10 +54,10 @@ let signed = await token.sign(JWK.Algorithm.ES256, keys);
|
|
|
59
54
|
```typescript
|
|
60
55
|
import { JWK } from "@sdxc/jwt";
|
|
61
56
|
|
|
62
|
-
// Hold this for the life of the
|
|
63
|
-
let keys = JWK.importRemote(new URL(
|
|
57
|
+
// Hold this for the life of the process: it caches the fetched key set.
|
|
58
|
+
let keys = await JWK.importRemote(new URL("https://auth.example.com/.well-known/jwks.json"));
|
|
64
59
|
|
|
65
|
-
let idToken = await IdToken.verify(rawToken,
|
|
60
|
+
let idToken = await IdToken.verify(rawToken, keys, {
|
|
66
61
|
issuer: "https://auth.example.com",
|
|
67
62
|
audience: clientId,
|
|
68
63
|
algorithms: [JWK.Algorithm.ES256],
|
|
@@ -73,57 +68,33 @@ idToken.subject; // "user-123"
|
|
|
73
68
|
idToken.email; // "ada@example.com"
|
|
74
69
|
```
|
|
75
70
|
|
|
76
|
-
### Publishing The Key Set
|
|
77
|
-
|
|
78
|
-
```typescript
|
|
79
|
-
import { JWK } from "@sdxc/jwt";
|
|
80
|
-
|
|
81
|
-
let keys = await JWK.signingKeys(storage);
|
|
82
|
-
|
|
83
|
-
return Response.json(JWK.toJSON(keys), {
|
|
84
|
-
headers: { "Cache-Control": "public, max-age=3600" },
|
|
85
|
-
});
|
|
86
|
-
```
|
|
87
|
-
|
|
88
71
|
## API
|
|
89
72
|
|
|
90
73
|
### `JWT`
|
|
91
74
|
|
|
92
|
-
A JWT claim set with the registered claims exposed as accessors.
|
|
75
|
+
A JWT claim set with the registered claims exposed as accessors. Subclass it to add accessors for the claims a kind of token carries, or use it directly when a token needs none of its own.
|
|
93
76
|
|
|
94
77
|
#### `new JWT(payload?: JWT.PayloadInput)`
|
|
95
78
|
|
|
96
|
-
Wraps a claim set.
|
|
97
|
-
|
|
98
|
-
**Parameters:**
|
|
99
|
-
|
|
100
|
-
- `payload`: The claims, defaulting to `{}` so a token can be built up through the setters
|
|
101
|
-
|
|
102
|
-
`exp`, `iat`, and `nbf` accept a length of time as well as a number. Written as text, the claim is that long from the moment the token is built; written as a number, it is the seconds since the epoch and is used as given. Either way `payload` holds the resolved seconds, so the rest of the class, signing, and verification all see one form.
|
|
103
|
-
|
|
104
|
-
```typescript
|
|
105
|
-
new JWT({ sub: "user-123", iat: "0s", exp: "1h" });
|
|
106
|
-
new JWT({ sub: "user-123", exp: Math.floor(Date.now() / 1000) + 3600 }); // the same token
|
|
107
|
-
```
|
|
79
|
+
Wraps a claim set, defaulting to `{}` so a token can be built up through the setters.
|
|
108
80
|
|
|
109
|
-
|
|
81
|
+
`exp`, `iat`, and `nbf` accept a duration string as well as a number. Written as text, the claim is that long from the moment the token is built; written as a number, it is used as the seconds since the epoch. Either way `payload` holds the resolved seconds.
|
|
110
82
|
|
|
111
|
-
|
|
83
|
+
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.
|
|
112
84
|
|
|
113
85
|
```typescript
|
|
114
|
-
let jwt = new JWT({ sub: "user-123", scope: "read" });
|
|
115
|
-
jwt.subject; // "user-123"
|
|
86
|
+
let jwt = new JWT({ sub: "user-123", scope: "read", exp: "1h" });
|
|
116
87
|
jwt.scope; // "read" — no accessor declared, read through the payload
|
|
117
88
|
jwt.missing; // null
|
|
118
89
|
```
|
|
119
90
|
|
|
120
91
|
#### `jwt.payload`
|
|
121
92
|
|
|
122
|
-
The raw claim set, readable whole
|
|
93
|
+
The raw claim set, readable whole, and the same object the accessors read through.
|
|
123
94
|
|
|
124
95
|
#### `jwt.parser`
|
|
125
96
|
|
|
126
|
-
Protected. Type-checked reads over `payload
|
|
97
|
+
Protected. Type-checked reads over `payload` for the accessors a subclass defines: `has`, `get`, `string`, `number`, `boolean`, and `object`. Every read but `has` throws on a malformed payload — a claim that is absent, or one holding another JSON type — rather than coercing, so `"true"` read as a `boolean` is an error. Ask with `has` first for an optional claim.
|
|
127
98
|
|
|
128
99
|
#### Registered Claim Accessors
|
|
129
100
|
|
|
@@ -140,97 +111,43 @@ Protected. Type-checked reads over `payload`, for the accessors a subclass defin
|
|
|
140
111
|
| `notBefore` | `nbf` | `Date \| null` | yes |
|
|
141
112
|
| `subject` | `sub` | `string \| null` | yes |
|
|
142
113
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
`expiresIn` counts the seconds left before `exp` and goes negative once it has passed, and `expired` follows from it. Both read `exp` in the seconds RFC 7519 defines, which is what `JWT.verify` enforces expiry against.
|
|
114
|
+
Each accessor answers `null` for an absent claim. The setters take `null` to drop the claim, and convert `Date` values to epoch seconds. `expiresIn` counts the seconds left before `exp` and goes negative once it has passed, and `expired` follows from it; both report what the claim says, which suits display and cache decisions, while `JWT.verify` is what enforces expiry.
|
|
146
115
|
|
|
147
116
|
#### `jwt.sign(algorithm: JWK.Algorithm, jwks: JWK.SigningKey[]): Promise<string>`
|
|
148
117
|
|
|
149
|
-
Signs this token with the first key in the set
|
|
150
|
-
|
|
151
|
-
**Returns:**
|
|
152
|
-
|
|
153
|
-
- The compact-serialized token
|
|
154
|
-
|
|
155
|
-
Throws when no key in the set was generated for that algorithm.
|
|
118
|
+
Signs this token with the first key in the set generated for that algorithm, writing that key's `kid` into the header, and returns the compact-serialized token. Throws when the set holds no key for the algorithm. `JWT.sign(jwt, algorithm, jwks)` is the static form, for signing a token you were handed.
|
|
156
119
|
|
|
157
120
|
#### `jwt.toJSON(): Record<string, unknown>`
|
|
158
121
|
|
|
159
|
-
The claims a subclass exposes, as a plain object
|
|
160
|
-
|
|
161
|
-
#### `JWT.sign(jwt: JWT, algorithm: JWK.Algorithm, jwks: JWK.SigningKey[]): Promise<string>`
|
|
162
|
-
|
|
163
|
-
The static form of `jwt.sign`, for signing a token you were handed.
|
|
122
|
+
The claims a subclass exposes, as a plain object, read from the getters declared on the instance's own prototype.
|
|
164
123
|
|
|
165
124
|
#### `JWT.verify(token: string, jwks: JWK.VerificationKeys, options?: JWT.VerifyOptions): Promise<JWT>`
|
|
166
125
|
|
|
167
|
-
Verifies a token and returns it as an instance of the class the method was called on.
|
|
126
|
+
Verifies a token and returns it as an instance of the class the method was called on. `jwks` is either the keys themselves as `VerificationKey[]` or a resolver from `JWK.importLocal` / `JWK.importRemote`; `options` carries `issuer`, `audience`, `algorithms`, `clockTolerance`, and the rest of `jose`'s verify options. Signature, `exp`, `nbf`, and whichever of `issuer` and `audience` are given are all checked, and the first failure throws.
|
|
168
127
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
- `token`: The compact-serialized token
|
|
172
|
-
- `jwks`: The keys themselves, or a resolver from `JWK.importLocal` / `JWK.importRemote`
|
|
173
|
-
- `options`: `issuer`, `audience`, `algorithms`, `clockTolerance`, and the rest of `JWT.VerifyOptions`
|
|
174
|
-
|
|
175
|
-
**Returns:**
|
|
176
|
-
|
|
177
|
-
- The verified token, typed as the class this was called on
|
|
178
|
-
|
|
179
|
-
Signature, `exp`, `nbf`, and whichever of `issuer` and `audience` are given are all checked, and the first failure throws — so reaching the return value means the claims are trustworthy.
|
|
180
|
-
|
|
181
|
-
The key is chosen per token, from the header the token carries: the key whose `kid` the header names is the one used, narrowed further by key type, curve, algorithm and intended use. A set that offers exactly one key for what the token asks for verifies it; a set that offers none, or several the token gives no way to choose between, is an error. Deciding per token is what lets an issuer publish several keys at once — during a rotation the retired key stays published and the tokens it signed keep verifying, while new tokens name the new key and get it.
|
|
182
|
-
|
|
183
|
-
Pass `algorithms`, listing the ones the caller expects. It is the pin that keeps a token naming one algorithm from being answered with a key published for another, once a set carries keys for more than one.
|
|
184
|
-
|
|
185
|
-
**Example:**
|
|
186
|
-
|
|
187
|
-
```typescript
|
|
188
|
-
let idToken = await IdToken.verify(raw, keys, {
|
|
189
|
-
issuer,
|
|
190
|
-
audience: clientId,
|
|
191
|
-
algorithms: [JWK.Algorithm.ES256],
|
|
192
|
-
});
|
|
193
|
-
```
|
|
128
|
+
The key is chosen per token from the header it carries: the key its `kid` names, narrowed by key type, curve, algorithm, and intended use. Exactly one match verifies the token; none, or several the token gives no way to choose between, is an error. Choosing per token is what lets an issuer publish several keys at once. Pass `algorithms` with the ones you expect, so a token naming one algorithm is answered only with a key published for it.
|
|
194
129
|
|
|
195
130
|
#### `JWT.decode(token: string): JWT`
|
|
196
131
|
|
|
197
|
-
Reads a token's claims
|
|
132
|
+
Reads a token's claims as the token presents them, as an instance of the class this was called on. Use it to decide how to verify a token — reading `iss` to pick a key set, for instance — and let `verify` be what every decision about the request rests on.
|
|
198
133
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
#### `JWT.Payload`
|
|
202
|
-
|
|
203
|
-
```typescript
|
|
204
|
-
type Payload = jose.JWTPayload;
|
|
205
|
-
```
|
|
134
|
+
#### Types
|
|
206
135
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
```typescript
|
|
210
|
-
type VerifyOptions = jose.JWTVerifyOptions;
|
|
211
|
-
```
|
|
136
|
+
`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`.
|
|
212
137
|
|
|
213
138
|
### `JWK`
|
|
214
139
|
|
|
215
140
|
#### `JWK.Algorithm`
|
|
216
141
|
|
|
217
|
-
The supported signature algorithms.
|
|
218
|
-
|
|
219
|
-
```typescript
|
|
220
|
-
JWK.Algorithm.ES256; // "ES256"
|
|
221
|
-
JWK.Algorithm.RS256; // "RS256"
|
|
222
|
-
JWK.Algorithm.EdDSA; // "EdDSA"
|
|
223
|
-
```
|
|
224
|
-
|
|
225
|
-
| Algorithm | `kty` | Reach for it when |
|
|
226
|
-
| --------- | ----- | --------------------------------------------------------- |
|
|
227
|
-
| `ES256` | `EC` | Signing here; it is what a bootstrapped key is minted for |
|
|
228
|
-
| `RS256` | `RSA` | Verifying an upstream identity provider's tokens |
|
|
229
|
-
| `EdDSA` | `OKP` | Signing, where a deterministic nonce is wanted |
|
|
142
|
+
The supported signature algorithms, as `JWK.Algorithm.ES256`, `.RS256`, and `.EdDSA`.
|
|
230
143
|
|
|
231
|
-
|
|
144
|
+
| Algorithm | `kty` | Reach for it when |
|
|
145
|
+
| --------- | ----- | ---------------------------------------------------- |
|
|
146
|
+
| `ES256` | `EC` | Signing; it is what a bootstrapped key is minted for |
|
|
147
|
+
| `RS256` | `RSA` | Verifying an upstream identity provider's tokens |
|
|
148
|
+
| `EdDSA` | `OKP` | Signing, where a deterministic nonce is wanted |
|
|
232
149
|
|
|
233
|
-
The names are also importable on their own, for a caller that
|
|
150
|
+
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:
|
|
234
151
|
|
|
235
152
|
```typescript
|
|
236
153
|
import * as Algorithm from "@sdxc/jwt/algorithm";
|
|
@@ -238,85 +155,42 @@ import * as Algorithm from "@sdxc/jwt/algorithm";
|
|
|
238
155
|
Object.values(Algorithm); // ["ES256", "RS256", "EdDSA"]
|
|
239
156
|
```
|
|
240
157
|
|
|
241
|
-
```typescript
|
|
242
|
-
import { ES256 } from "@sdxc/jwt/algorithm";
|
|
243
|
-
```
|
|
244
|
-
|
|
245
|
-
That entry point holds the three constants and nothing else, so importing it costs nothing beyond the names.
|
|
246
|
-
|
|
247
|
-
All three coexist in one key set. Verification picks a key by the `kid` and the algorithm a token names, so a set publishing several resolves the same way a set of one does.
|
|
248
|
-
|
|
249
158
|
#### `JWK.generateKeyPair(alg: JWK.Algorithm): Promise<JWK.SerializedKeyPair>`
|
|
250
159
|
|
|
251
|
-
Generates a
|
|
252
|
-
|
|
253
|
-
Keys are generated extractable on purpose. A pair that cannot be exported cannot be stored, and a signing key that only exists in one isolate signs tokens no other isolate can verify.
|
|
160
|
+
Generates a key pair in serialized form: PEM strings and a millisecond timestamp, so the pair survives `JSON.stringify` into a bucket or a text column. Keys are extractable, so a pair written by one process imports in every other one.
|
|
254
161
|
|
|
255
162
|
#### `JWK.importKeyPair(value: JWK.SerializedKeyPair): Promise<JWK.KeyPair>`
|
|
256
163
|
|
|
257
|
-
Imports a stored pair back into usable `CryptoKey` objects
|
|
258
|
-
|
|
259
|
-
Stamping happens here rather than at publish time, so the identifier a token header carries and the identifier the JWKS advertises cannot drift apart.
|
|
260
|
-
|
|
261
|
-
**Example:**
|
|
262
|
-
|
|
263
|
-
```typescript
|
|
264
|
-
let pair = await JWK.importKeyPair({
|
|
265
|
-
id: record.id,
|
|
266
|
-
alg: JWK.Algorithm.ES256,
|
|
267
|
-
publicKey: record.public_key,
|
|
268
|
-
privateKey: record.private_key,
|
|
269
|
-
created: new Date(record.created_at).getTime(),
|
|
270
|
-
});
|
|
271
|
-
```
|
|
164
|
+
Imports a stored pair back into usable `CryptoKey` objects and attaches the public half as a JWK stamped with `kid` and `use: "sig"`, so the identifier a token header carries and the one the JWKS advertises stay the same. The public half imports extractable; the private half stays confined to the runtime. Keys held in database rows need nothing beyond this and `generateKeyPair`; cache the result, since each call imports two keys.
|
|
272
165
|
|
|
273
166
|
#### `JWK.signingKeys(storage: KeyStorage): Promise<JWK.KeyPair[]>`
|
|
274
167
|
|
|
275
|
-
Loads every signing key out of storage,
|
|
276
|
-
|
|
277
|
-
Generation time is the whole ordering, so a store holding keys for several algorithms comes back as one sequence and `sign` takes the newest key generated for the algorithm it was asked for.
|
|
278
|
-
|
|
279
|
-
Point this at the bucket the issuer already keeps its keys in. Against an empty one it bootstraps a key, and tokens signed with it verify once every relying party has refreshed its copy of the published set.
|
|
168
|
+
Loads every signing key out of storage, newest first, which is the order `sign` picks from and `toJSON` publishes in. A set holding several is the normal state during a rotation: the newest signs, and the older ones stay published so the tokens they signed keep verifying. When nothing usable is stored, an ES256 key is generated, written back, and returned.
|
|
280
169
|
|
|
281
170
|
#### `JWK.importLocal(jwks: jose.JSONWebKeySet): Promise<JWK.KeyResolver>`
|
|
282
171
|
|
|
283
|
-
Turns a key set that is already in hand into a resolver `JWT.verify`
|
|
172
|
+
Turns a key set that is already in hand into a resolver `JWT.verify` accepts.
|
|
284
173
|
|
|
285
174
|
#### `JWK.importRemote(url: URL, options?: JWK.RemoteOptions): Promise<JWK.KeyResolver>`
|
|
286
175
|
|
|
287
|
-
Points a resolver at a JWKS endpoint. The document is fetched when a token first needs it
|
|
288
|
-
|
|
289
|
-
The held copy is fetched again, at most once per cooldown window, when a token names a `kid` it does not carry. That is what carries a verifier across a rotation between deploys: the first token signed by a newly published key is what pulls that key in.
|
|
290
|
-
|
|
291
|
-
Both resolve per token rather than up front, because which key a token needs is what the token itself says. `JWT.verify` describes the selection that follows.
|
|
176
|
+
Points a resolver at a JWKS endpoint. The document is fetched when a token first needs it and then held, so hold the resolver for the life of the process and every verification shares one fetched set. It is fetched again, at most once per cooldown window, when a token names a `kid` the held copy lacks — which is what carries a verifier across a rotation between deploys.
|
|
292
177
|
|
|
293
178
|
#### `JWK.toJSON(keys: JWK.KeyPair[]): jose.JSONWebKeySet`
|
|
294
179
|
|
|
295
|
-
Renders key pairs as the JSON a `/.well-known/jwks.json` endpoint serves.
|
|
296
|
-
|
|
297
|
-
| `kty` | Published entry |
|
|
298
|
-
| ----- | ------------------------------------ |
|
|
299
|
-
| `EC` | `crv`, `x`, `y`, `kty`, `kid`, `alg` |
|
|
300
|
-
| `OKP` | `crv`, `x`, `kty`, `kid`, `alg` |
|
|
301
|
-
| `RSA` | `e`, `n`, `kty`, `kid`, `alg` |
|
|
302
|
-
|
|
303
|
-
The shape of the output is what guarantees a private key cannot reach the endpoint through a field nobody thought about: `d` on any of them, and `p`, `q`, `dp`, `dq` and `qi` on an RSA key besides. A key type absent from that table is one whose private parameters have never been enumerated, so it **throws** rather than publishing an entry nobody has vetted — and it throws for the document as a whole, so the rest of the set does not go out alongside an entry a relying party cannot use.
|
|
304
|
-
|
|
305
|
-
`alg` is published because a set carrying more than one algorithm gives a relying party nothing else to select on before it reaches the signature.
|
|
180
|
+
Renders key pairs as the JSON a `/.well-known/jwks.json` endpoint serves. Each entry carries `kty`, `kid`, `alg`, and the public parameters its key type publishes — `crv`, `x`, `y` for `EC`, `crv`, `x` for `OKP`, `e`, `n` for `RSA` — which is what keeps private components out of the document. A key type outside that list throws, for the document as a whole.
|
|
306
181
|
|
|
307
182
|
#### Types
|
|
308
183
|
|
|
309
184
|
```typescript
|
|
310
185
|
interface KeyPair {
|
|
311
|
-
id: string;
|
|
186
|
+
id: string; // published as the `kid`
|
|
312
187
|
alg: Algorithm;
|
|
313
188
|
public: jose.CryptoKey;
|
|
314
189
|
private: jose.CryptoKey;
|
|
315
190
|
created: Date;
|
|
316
|
-
expired?: Date;
|
|
317
|
-
jwk: jose.JWK;
|
|
191
|
+
expired?: Date; // set once the pair is retired from signing
|
|
192
|
+
jwk: jose.JWK; // public half, ready to publish
|
|
318
193
|
}
|
|
319
|
-
|
|
320
194
|
interface SerializedKeyPair {
|
|
321
195
|
id: `${string}-${string}-${string}-${string}-${string}`;
|
|
322
196
|
alg: Algorithm;
|
|
@@ -325,27 +199,21 @@ interface SerializedKeyPair {
|
|
|
325
199
|
created: number; // epoch milliseconds
|
|
326
200
|
expired?: Date;
|
|
327
201
|
}
|
|
328
|
-
|
|
329
202
|
interface SigningKey {
|
|
330
203
|
id: string;
|
|
331
204
|
alg: string;
|
|
332
205
|
private: jose.CryptoKey;
|
|
333
206
|
}
|
|
334
|
-
|
|
335
207
|
interface VerificationKey {
|
|
336
|
-
jwk: jose.JWK;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
type KeyResolver = jose.JWTVerifyGetKey; // answers with the key a token calls for
|
|
340
|
-
type VerificationKeys = KeyResolver | VerificationKey[];
|
|
341
|
-
type RemoteOptions = jose.RemoteJWKSetOptions;
|
|
208
|
+
jwk: jose.JWK;
|
|
209
|
+
} // carries the `kid` a token names
|
|
342
210
|
```
|
|
343
211
|
|
|
344
|
-
`KeyPair` satisfies both `SigningKey` and `VerificationKey`, so a full pair is accepted anywhere either is.
|
|
212
|
+
`JWK.KeyResolver` is `jose.JWTVerifyGetKey`, which answers with the key a given token calls for; `JWK.VerificationKeys` is `KeyResolver | VerificationKey[]`, what `JWT.verify` takes; `JWK.RemoteOptions` is `jose.RemoteJWKSetOptions`. `KeyPair` satisfies both `SigningKey` and `VerificationKey`, so a full pair is accepted anywhere either is.
|
|
345
213
|
|
|
346
214
|
### `KeyStorage`
|
|
347
215
|
|
|
348
|
-
The storage contract `JWK.signingKeys` is written against
|
|
216
|
+
The storage contract `JWK.signingKeys` is written against: a key/value store of `File` objects that pages by prefix.
|
|
349
217
|
|
|
350
218
|
```typescript
|
|
351
219
|
interface KeyStorage {
|
|
@@ -353,52 +221,42 @@ interface KeyStorage {
|
|
|
353
221
|
list(options?: KeyStorageListOptions): KeyStorageListResult | Promise<KeyStorageListResult>;
|
|
354
222
|
set(key: string, file: File): void | Promise<void>;
|
|
355
223
|
}
|
|
356
|
-
```
|
|
357
|
-
|
|
358
|
-
Three operations, each allowed to be synchronous or to return a promise — which is what lets a plain in-memory object satisfy it in a test while a bucket client satisfies it in production. Any object store that pages by prefix with an opaque cursor fits without an adapter.
|
|
359
|
-
|
|
360
|
-
Reading, listing and writing are the whole contract: a key is written once and kept, so that every token it ever signed stays verifiable.
|
|
361
|
-
|
|
362
|
-
### Errors
|
|
363
|
-
|
|
364
|
-
Claim reads through `this.parser` **throw** rather than returning a `Result`, which is a deliberate departure from the rest of this monorepo. The reads happen inside property getters, and a getter has no room for a `Result` without changing the type of every claim accessor in every token class and forcing each call site to unwrap a value that has already been through signature verification.
|
|
365
|
-
|
|
366
|
-
| Error | Raised when |
|
|
367
|
-
| ------------------ | ------------------------------------------------------- |
|
|
368
|
-
| `ParserError` | Base class for both |
|
|
369
|
-
| `MissingKeyError` | A claim an accessor requires is absent from the payload |
|
|
370
|
-
| `InvalidTypeError` | A claim is present but holds the wrong JSON type |
|
|
371
224
|
|
|
372
|
-
|
|
225
|
+
interface KeyStorageListOptions {
|
|
226
|
+
cursor?: string;
|
|
227
|
+
limit?: number;
|
|
228
|
+
prefix?: string;
|
|
229
|
+
}
|
|
230
|
+
interface KeyStorageListResult {
|
|
231
|
+
cursor?: string;
|
|
232
|
+
files: { key: string }[];
|
|
233
|
+
}
|
|
234
|
+
```
|
|
373
235
|
|
|
374
|
-
`
|
|
236
|
+
To implement it: `get` answers `null` for a key nothing is stored under; `list` returns the entries whose key starts with `prefix`, at most `limit` of them, resuming from `cursor`, and carries a `cursor` of its own only while further pages remain; `set` replaces whatever is stored under the key. Each method may be synchronous or return a promise, so a plain object satisfies the contract in a test while an object store satisfies it in production. `signingKeys` writes each pair as a JSON `SerializedKeyPair` under the key `signing:key:<id>` and lists with the prefix `signing:key` to find them again, so keep every key ever written and every token it signed stays verifiable.
|
|
375
237
|
|
|
376
238
|
## Rotation, And The Order To Roll It Out In
|
|
377
239
|
|
|
378
|
-
A rotation adds a key
|
|
240
|
+
A rotation adds a key: the issuer writes a new pair into storage, `signingKeys` returns it first, `sign` picks it as the newest, and the retired key stays in the published JWKS so the tokens it signed keep verifying until they expire. That rests on every verifier resolving a key per token by `kid`, which makes the rollout order a deployment constraint:
|
|
379
241
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
1. Deploy the verifiers first — everything that verifies tokens against the published set — and confirm they are live on a version that resolves a key per token.
|
|
242
|
+
1. Deploy the verifiers first, and confirm they are live on a version that resolves a key per token.
|
|
383
243
|
2. Then let the issuer publish more than one key.
|
|
384
244
|
|
|
385
|
-
In that order every verifier can already tell two published keys apart on the day a second one appears. A verifier still on a version that resolves a single key up front has no way to choose between them, and it is every verification it does that stops, not a few.
|
|
386
|
-
|
|
387
245
|
A verifier picks a newly published key up when a token first names it, so keep a retired key published for at least as long as the tokens it signed can live.
|
|
388
246
|
|
|
389
247
|
## Pattern: Verifying An Upstream ID Token
|
|
390
248
|
|
|
391
|
-
Resolve the key set once,
|
|
392
|
-
|
|
393
|
-
Pin `algorithms` to what the provider actually signs with, which is usually `RS256`. It is the provider's choice, not yours, so read its discovery document rather than assuming.
|
|
249
|
+
Resolve the key set once and hold it, so every verification shares one fetched document. Pin `algorithms` to what the provider signs with — that is its choice, so read its discovery document rather than assuming — and treat every failure, from an unreachable JWKS to a wrong issuer, as the same "not authenticated" outcome.
|
|
394
250
|
|
|
395
251
|
```typescript
|
|
396
|
-
|
|
252
|
+
import { JWK, JWT } from "@sdxc/jwt";
|
|
253
|
+
|
|
254
|
+
let keys = JWK.importRemote(new URL("https://accounts.example.com/.well-known/jwks.json"));
|
|
397
255
|
|
|
398
|
-
export async function
|
|
256
|
+
export async function verifyIdToken(raw: string, clientId: string) {
|
|
399
257
|
try {
|
|
400
|
-
return await
|
|
401
|
-
issuer: "https://
|
|
258
|
+
return await JWT.verify(raw, await keys, {
|
|
259
|
+
issuer: "https://accounts.example.com",
|
|
402
260
|
audience: clientId,
|
|
403
261
|
algorithms: [JWK.Algorithm.RS256],
|
|
404
262
|
clockTolerance: 60,
|
|
@@ -409,42 +267,66 @@ export async function verify(raw: string) {
|
|
|
409
267
|
}
|
|
410
268
|
```
|
|
411
269
|
|
|
412
|
-
|
|
270
|
+
A subclass accessor for a claim the provider treats as optional throws from the getter, so keep those reads inside the same `try`.
|
|
413
271
|
|
|
414
272
|
## Pattern: Keys In A Database Instead Of A Bucket
|
|
415
273
|
|
|
416
|
-
`signingKeys` is a convenience over one
|
|
274
|
+
`JWK.signingKeys` is a convenience over one storage shape, the `KeyStorage` one. When keys live in rows instead, drive `generateKeyPair` and `importKeyPair` directly against the database client — those two are the whole contract, and what they hand back is the same `JWK.KeyPair[]` that `sign` and `JWK.toJSON` take.
|
|
417
275
|
|
|
418
276
|
```typescript
|
|
277
|
+
import { JWK } from "@sdxc/jwt";
|
|
278
|
+
|
|
279
|
+
// Minting: a serialized pair is PEM strings and a timestamp, so it stores as plain columns.
|
|
419
280
|
let serialized = await JWK.generateKeyPair(JWK.Algorithm.ES256);
|
|
420
281
|
|
|
421
|
-
await db.
|
|
282
|
+
await db.insert("signing_keys", {
|
|
422
283
|
id: serialized.id,
|
|
284
|
+
alg: serialized.alg,
|
|
423
285
|
public_key: serialized.publicKey,
|
|
424
286
|
private_key: serialized.privateKey,
|
|
425
287
|
created_at: new Date(serialized.created).toISOString(),
|
|
426
288
|
});
|
|
427
289
|
|
|
428
|
-
|
|
290
|
+
// Signing and publishing: import every stored row back, newest first.
|
|
291
|
+
let rows = await db.select("signing_keys", { orderBy: "created_at desc" });
|
|
292
|
+
|
|
293
|
+
let keys = await Promise.all(
|
|
294
|
+
rows.map((row) =>
|
|
295
|
+
JWK.importKeyPair({
|
|
296
|
+
id: row.id as JWK.SerializedKeyPair["id"], // a UUID, as `generateKeyPair` wrote it
|
|
297
|
+
alg: row.alg,
|
|
298
|
+
publicKey: row.public_key,
|
|
299
|
+
privateKey: row.private_key,
|
|
300
|
+
created: new Date(row.created_at).getTime(),
|
|
301
|
+
}),
|
|
302
|
+
),
|
|
303
|
+
);
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
Each import loads two keys, so cache the resulting pairs for the life of a request at minimum.
|
|
307
|
+
|
|
308
|
+
## Versioning
|
|
309
|
+
|
|
310
|
+
Releases are dated rather than semantic. A version is the UTC date it was published, written `YYYY.M.D`, so `2026.9.4` is the release from 4 September 2026. At most one release goes out per day.
|
|
311
|
+
|
|
312
|
+
Those numbers say when, not what: a later date means a later release and carries no compatibility promise. Any release may change or remove an export.
|
|
313
|
+
|
|
314
|
+
Depend on one exact date, and move it when you are ready to take the change:
|
|
315
|
+
|
|
316
|
+
```json
|
|
317
|
+
{
|
|
318
|
+
"dependencies": {
|
|
319
|
+
"@sdxc/jwt": "2026.9.4"
|
|
320
|
+
}
|
|
321
|
+
}
|
|
429
322
|
```
|
|
430
323
|
|
|
431
|
-
|
|
324
|
+
A caret or tilde range reads the date as major, minor and patch, so it accepts every later release in the same year. An exact version keeps the upgrade yours to schedule.
|
|
432
325
|
|
|
433
|
-
##
|
|
326
|
+
## License
|
|
434
327
|
|
|
435
|
-
|
|
436
|
-
- [`@sdxc/oidc-provider`](/packages/oidc-provider) - OIDC/OAuth2 provider engine, whose token value objects are built on `JWT`
|
|
328
|
+
MIT
|
|
437
329
|
|
|
438
|
-
##
|
|
330
|
+
## Author
|
|
439
331
|
|
|
440
|
-
|
|
441
|
-
2. **Narrow the accessors you override** - a token that always carries `sub` should expose `string`, not `string | null`; that is the difference between one check and one at every call site.
|
|
442
|
-
3. **Resolve a remote JWKS once** - the resolver holds the fetched key set. Hold it in a container singleton or at module scope, so every request shares one.
|
|
443
|
-
4. **Route with `decode`, decide with `verify`** - what `decode` returns is the token's own account of itself, read without any check.
|
|
444
|
-
5. **Always pass `issuer` and `audience` to `verify`** - a valid signature from the right issuer for the wrong audience is still someone else's token.
|
|
445
|
-
6. **Give `clockTolerance` a value** - a small window (60 seconds is typical) absorbs drift between the issuer and the verifier without meaningfully extending a token's life.
|
|
446
|
-
7. **Use `expired` for display, not for access control** - it reports what the claim says, while `verify` is what rejects a token on it.
|
|
447
|
-
8. **Roll a rotation out verifier-first** - see [the ordering above](#rotation-and-the-order-to-roll-it-out-in); every verifier resolves a key per token before the issuer publishes a second one.
|
|
448
|
-
9. **Guard optional claims with `has`** - the typed reads throw, and `has` is the only cheap way to ask first.
|
|
449
|
-
10. **Point `signingKeys` at the bucket that already holds the keys** - against an empty one it bootstraps a fresh key, which verifies once every verifier has refreshed its copy of the published set.
|
|
450
|
-
11. **Pin `algorithms` to what the signer uses, not to what you issue** - `RS256` for an upstream identity provider, `ES256` or `EdDSA` for a token minted here.
|
|
332
|
+
[Sergio Xalambrí](https://sergiodxa.com)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdxc/jwt",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2026.9.5",
|
|
4
4
|
"description": "JWT payload classes and the keys that sign them",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
"./algorithm": "./dist/algorithm.js"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@sdxc/duration": "
|
|
12
|
+
"@sdxc/duration": "2026.9.5",
|
|
13
13
|
"jose": "^6.2.10"
|
|
14
14
|
},
|
|
15
|
-
"gitHead": "
|
|
15
|
+
"gitHead": "d03dcaf11df41694a27b2c686b5d0ce67d3d77d3",
|
|
16
16
|
"publishConfig": {
|
|
17
17
|
"access": "public"
|
|
18
18
|
},
|