@sdxc/jwt 0.0.0-pre.1
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/LICENSE.md +21 -0
- package/README.md +450 -0
- package/dist/algorithm.d.ts +25 -0
- package/dist/algorithm.js +25 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +12 -0
- package/dist/jwk.d.ts +176 -0
- package/dist/jwk.js +210 -0
- package/dist/jwt.d.ts +217 -0
- package/dist/jwt.js +322 -0
- package/dist/key-storage.d.ts +59 -0
- package/dist/key-storage.js +12 -0
- package/dist/lib/parser.d.ts +136 -0
- package/dist/lib/parser.js +190 -0
- package/package.json +24 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sergio Xalambrí
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
# @sdxc/jwt
|
|
2
|
+
|
|
3
|
+
JWT payload classes and the ES256, RS256 and EdDSA keys that sign them.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
A decoded JWT is a bag of `unknown`. Reading claims off it directly scatters the same string literals and the same type checks over every call site, and nothing stops one of them from trusting `payload.email` on a token whose issuer never sends that claim.
|
|
8
|
+
|
|
9
|
+
This package models a kind of token as a class instead. `JWT` carries the registered claims as typed accessors; an application subclasses it, narrows the accessors its tokens guarantee, and adds the ones it carries beyond the registered set — each one a single type-checked read against the claim set. Verifying through that subclass returns an instance of it, so the claims a handler reads and the claims the token class declares are the same list.
|
|
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.
|
|
12
|
+
|
|
13
|
+
An object model over JWT claim sets and the keys that sign them, then, sitting on top of a JOSE implementation that does the signing and verifying itself.
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
### Describing A Kind Of Token
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
import { JWT } from "@sdxc/jwt";
|
|
21
|
+
|
|
22
|
+
export default class IdToken extends JWT {
|
|
23
|
+
// Narrowed: this token always carries `sub`, so the accessor is `string`, not
|
|
24
|
+
// `string | null`.
|
|
25
|
+
override get subject() {
|
|
26
|
+
return this.parser.string("sub");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get email() {
|
|
30
|
+
return this.parser.string("email");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
get emailVerified() {
|
|
34
|
+
return this.parser.boolean("email_verified");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Issuing A Token
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
import { JWK, JWT } from "@sdxc/jwt";
|
|
43
|
+
|
|
44
|
+
let keys = await JWK.signingKeys(storage);
|
|
45
|
+
|
|
46
|
+
let token = new JWT({
|
|
47
|
+
iss: "https://auth.example.com",
|
|
48
|
+
sub: user.id,
|
|
49
|
+
aud: client.id,
|
|
50
|
+
iat: "0s",
|
|
51
|
+
exp: "1h",
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
let signed = await token.sign(JWK.Algorithm.ES256, keys);
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Verifying One
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
import { JWK } from "@sdxc/jwt";
|
|
61
|
+
|
|
62
|
+
// Hold this for the life of the isolate: it caches the fetched key set.
|
|
63
|
+
let keys = JWK.importRemote(new URL(jwksUrl));
|
|
64
|
+
|
|
65
|
+
let idToken = await IdToken.verify(rawToken, await keys, {
|
|
66
|
+
issuer: "https://auth.example.com",
|
|
67
|
+
audience: clientId,
|
|
68
|
+
algorithms: [JWK.Algorithm.ES256],
|
|
69
|
+
clockTolerance: 60,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
idToken.subject; // "user-123"
|
|
73
|
+
idToken.email; // "ada@example.com"
|
|
74
|
+
```
|
|
75
|
+
|
|
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
|
+
## API
|
|
89
|
+
|
|
90
|
+
### `JWT`
|
|
91
|
+
|
|
92
|
+
A JWT claim set with the registered claims exposed as accessors. Meant to be subclassed; usable directly when a token needs no accessors of its own.
|
|
93
|
+
|
|
94
|
+
#### `new JWT(payload?: JWT.PayloadInput)`
|
|
95
|
+
|
|
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
|
+
```
|
|
108
|
+
|
|
109
|
+
Instances are proxied: a claim with no accessor is still readable by name and returns `null` when absent, and assigning an unknown property writes it into the payload so it is carried into the signature.
|
|
110
|
+
|
|
111
|
+
**Example:**
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
let jwt = new JWT({ sub: "user-123", scope: "read" });
|
|
115
|
+
jwt.subject; // "user-123"
|
|
116
|
+
jwt.scope; // "read" — no accessor declared, read through the payload
|
|
117
|
+
jwt.missing; // null
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
#### `jwt.payload`
|
|
121
|
+
|
|
122
|
+
The raw claim set, readable whole. This is the same object the accessors read through, so a caller that wants the claim bag and a subclass that wants typed accessors can use the same instance.
|
|
123
|
+
|
|
124
|
+
#### `jwt.parser`
|
|
125
|
+
|
|
126
|
+
Protected. Type-checked reads over `payload`, for the accessors a subclass defines: `string`, `number`, `boolean`, `object`, and `has`. Each read throws when the claim is missing or holds the wrong type — see [Errors](#errors).
|
|
127
|
+
|
|
128
|
+
#### Registered Claim Accessors
|
|
129
|
+
|
|
130
|
+
| Accessor | Claim | Type | Writable |
|
|
131
|
+
| ---------------- | ----- | ---------------------------- | -------- |
|
|
132
|
+
| `audience` | `aud` | `string \| string[] \| null` | yes |
|
|
133
|
+
| `expirationTime` | `exp` | `number \| null` | yes |
|
|
134
|
+
| `expiresIn` | `exp` | `number \| null` | no |
|
|
135
|
+
| `expiresAt` | `exp` | `Date \| null` | no |
|
|
136
|
+
| `expired` | `exp` | `boolean` | no |
|
|
137
|
+
| `issuedAt` | `iat` | `Date \| null` | yes |
|
|
138
|
+
| `issuer` | `iss` | `string \| null` | yes |
|
|
139
|
+
| `id` | `jti` | `string \| null` | yes |
|
|
140
|
+
| `notBefore` | `nbf` | `Date \| null` | yes |
|
|
141
|
+
| `subject` | `sub` | `string \| null` | yes |
|
|
142
|
+
|
|
143
|
+
Every accessor answers `null` for a claim that is absent, so reading one never throws on a token that simply does not carry it. The setters take `null` to drop the claim, and convert `Date` values to the epoch seconds the RFC defines.
|
|
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.
|
|
146
|
+
|
|
147
|
+
#### `jwt.sign(algorithm: JWK.Algorithm, jwks: JWK.SigningKey[]): Promise<string>`
|
|
148
|
+
|
|
149
|
+
Signs this token with the first key in the set matching the algorithm, writing that key's `kid` into the header.
|
|
150
|
+
|
|
151
|
+
**Returns:**
|
|
152
|
+
|
|
153
|
+
- The compact-serialized token
|
|
154
|
+
|
|
155
|
+
Throws when no key in the set was generated for that algorithm.
|
|
156
|
+
|
|
157
|
+
#### `jwt.toJSON(): Record<string, unknown>`
|
|
158
|
+
|
|
159
|
+
The claims a subclass exposes, as a plain object. Reads the getters declared directly on the instance's own prototype, so the result is the subclass's view of the token: the claims it models.
|
|
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.
|
|
164
|
+
|
|
165
|
+
#### `JWT.verify(token: string, jwks: JWK.VerificationKeys, options?: JWT.VerifyOptions): Promise<JWT>`
|
|
166
|
+
|
|
167
|
+
Verifies a token and returns it as an instance of the class the method was called on.
|
|
168
|
+
|
|
169
|
+
**Parameters:**
|
|
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
|
+
```
|
|
194
|
+
|
|
195
|
+
#### `JWT.decode(token: string): JWT`
|
|
196
|
+
|
|
197
|
+
Reads a token's claims without checking anything, as an instance of the class this was called on.
|
|
198
|
+
|
|
199
|
+
What comes back is the token's own account of itself, read without any check. Use it to decide how to verify a token — reading `iss` to pick a JWKS, for instance — and let `verify` be what every decision about the request rests on.
|
|
200
|
+
|
|
201
|
+
#### `JWT.Payload`
|
|
202
|
+
|
|
203
|
+
```typescript
|
|
204
|
+
type Payload = jose.JWTPayload;
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
#### `JWT.VerifyOptions`
|
|
208
|
+
|
|
209
|
+
```typescript
|
|
210
|
+
type VerifyOptions = jose.JWTVerifyOptions;
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### `JWK`
|
|
214
|
+
|
|
215
|
+
#### `JWK.Algorithm`
|
|
216
|
+
|
|
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 |
|
|
230
|
+
|
|
231
|
+
`RS256` earns its place on the verifying side: an upstream identity provider signs with what it chooses, and that is commonly `RS256`. `EdDSA` derives each signature's nonce from the key and the message rather than drawing a fresh one, which puts the nonce reuse that recovers an ECDSA private key out of reach.
|
|
232
|
+
|
|
233
|
+
The names are also importable on their own, for a caller that needs to read or list them without the key machinery behind them:
|
|
234
|
+
|
|
235
|
+
```typescript
|
|
236
|
+
import * as Algorithm from "@sdxc/jwt/algorithm";
|
|
237
|
+
|
|
238
|
+
Object.values(Algorithm); // ["ES256", "RS256", "EdDSA"]
|
|
239
|
+
```
|
|
240
|
+
|
|
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
|
+
#### `JWK.generateKeyPair(alg: JWK.Algorithm): Promise<JWK.SerializedKeyPair>`
|
|
250
|
+
|
|
251
|
+
Generates a new key pair in serialized form: PEM strings and a millisecond timestamp, so the whole pair survives `JSON.stringify` into a bucket or a text column.
|
|
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.
|
|
254
|
+
|
|
255
|
+
#### `JWK.importKeyPair(value: JWK.SerializedKeyPair): Promise<JWK.KeyPair>`
|
|
256
|
+
|
|
257
|
+
Imports a stored pair back into usable `CryptoKey` objects, and attaches the public half as a JWK stamped with `kid` and `use: "sig"`.
|
|
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
|
+
```
|
|
272
|
+
|
|
273
|
+
#### `JWK.signingKeys(storage: KeyStorage): Promise<JWK.KeyPair[]>`
|
|
274
|
+
|
|
275
|
+
Loads every signing key out of storage, generating one on first use. Newest first, which is the order `sign` relies on to pick what to sign with and the order `toJSON` publishes them in. A set holding several is the normal state during a rotation: the newest signs, the older ones stay published so the tokens they signed keep verifying. A new ES256 key is minted when nothing usable is stored at all.
|
|
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.
|
|
280
|
+
|
|
281
|
+
#### `JWK.importLocal(jwks: jose.JSONWebKeySet): Promise<JWK.KeyResolver>`
|
|
282
|
+
|
|
283
|
+
Turns a key set that is already in hand into a resolver `JWT.verify` can use.
|
|
284
|
+
|
|
285
|
+
#### `JWK.importRemote(url: URL, options?: JWK.RemoteOptions): Promise<JWK.KeyResolver>`
|
|
286
|
+
|
|
287
|
+
Points a resolver at a JWKS endpoint. The document is fetched when a token first needs it, then held — so hold the resolver for the life of the isolate, and every verification shares one fetched key set.
|
|
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.
|
|
292
|
+
|
|
293
|
+
#### `JWK.toJSON(keys: JWK.KeyPair[]): jose.JSONWebKeySet`
|
|
294
|
+
|
|
295
|
+
Renders key pairs as the JSON a `/.well-known/jwks.json` endpoint serves. Every entry is built out of the parameters its key type publishes, drawn only from the public half, alongside the `kid` and `alg` a relying party selects on:
|
|
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.
|
|
306
|
+
|
|
307
|
+
#### Types
|
|
308
|
+
|
|
309
|
+
```typescript
|
|
310
|
+
interface KeyPair {
|
|
311
|
+
id: string;
|
|
312
|
+
alg: Algorithm;
|
|
313
|
+
public: jose.CryptoKey;
|
|
314
|
+
private: jose.CryptoKey;
|
|
315
|
+
created: Date;
|
|
316
|
+
expired?: Date;
|
|
317
|
+
jwk: jose.JWK;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
interface SerializedKeyPair {
|
|
321
|
+
id: `${string}-${string}-${string}-${string}-${string}`;
|
|
322
|
+
alg: Algorithm;
|
|
323
|
+
publicKey: string; // SPKI PEM
|
|
324
|
+
privateKey: string; // PKCS#8 PEM
|
|
325
|
+
created: number; // epoch milliseconds
|
|
326
|
+
expired?: Date;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
interface SigningKey {
|
|
330
|
+
id: string;
|
|
331
|
+
alg: string;
|
|
332
|
+
private: jose.CryptoKey;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
interface VerificationKey {
|
|
336
|
+
jwk: jose.JWK; // the published key, carrying the `kid` a token names
|
|
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;
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
`KeyPair` satisfies both `SigningKey` and `VerificationKey`, so a full pair is accepted anywhere either is.
|
|
345
|
+
|
|
346
|
+
### `KeyStorage`
|
|
347
|
+
|
|
348
|
+
The storage contract `JWK.signingKeys` is written against, and nothing more.
|
|
349
|
+
|
|
350
|
+
```typescript
|
|
351
|
+
interface KeyStorage {
|
|
352
|
+
get(key: string): File | null | Promise<File | null>;
|
|
353
|
+
list(options?: KeyStorageListOptions): KeyStorageListResult | Promise<KeyStorageListResult>;
|
|
354
|
+
set(key: string, file: File): void | Promise<void>;
|
|
355
|
+
}
|
|
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
|
+
|
|
372
|
+
These classes are internal — a caller catches them by name (`ParserMissingKeyError`, `ParserInvalidTypeError`) or, more usually, does not catch them at all: a claim missing from a token that has already verified means the issuer sent something the token class does not model, which is closer to a bug than to a runtime condition worth branching on.
|
|
373
|
+
|
|
374
|
+
`boolean` is strict. The string `"true"` is a type error, not a `true` — silently coercing it would turn an identity provider's malformed `email_verified` into a trusted `true`.
|
|
375
|
+
|
|
376
|
+
## Rotation, And The Order To Roll It Out In
|
|
377
|
+
|
|
378
|
+
A rotation adds a key. The issuer writes a new pair into storage, `signingKeys` returns it first, `sign` picks it because it is the newest, and the retired key stays in the published JWKS so that the tokens it already signed keep verifying until they expire. Keys are kept, which is what makes that possible.
|
|
379
|
+
|
|
380
|
+
That rests on every verifier being able to pick a key by `kid`, which makes the rollout order a **deployment constraint as much as a code one**. Issuer and verifiers are deployed separately, so:
|
|
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.
|
|
383
|
+
2. Then let the issuer publish more than one key.
|
|
384
|
+
|
|
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
|
+
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
|
+
|
|
389
|
+
## Pattern: Verifying An Upstream ID Token
|
|
390
|
+
|
|
391
|
+
Resolve the key set once, at module scope or in a container singleton, and treat any failure — an unreachable JWKS, a bad signature, a wrong issuer — as the same "not authenticated" outcome.
|
|
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.
|
|
394
|
+
|
|
395
|
+
```typescript
|
|
396
|
+
let verificationKey = JWK.importRemote(new URL(jwksUrl));
|
|
397
|
+
|
|
398
|
+
export async function verify(raw: string) {
|
|
399
|
+
try {
|
|
400
|
+
return await IdToken.verify(raw, await verificationKey, {
|
|
401
|
+
issuer: "https://auth.example.com",
|
|
402
|
+
audience: clientId,
|
|
403
|
+
algorithms: [JWK.Algorithm.RS256],
|
|
404
|
+
clockTolerance: 60,
|
|
405
|
+
});
|
|
406
|
+
} catch {
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
Wrap the accessor reads in the same `try` when the caller reads claims the issuer treats as optional: those throw from the getter itself.
|
|
413
|
+
|
|
414
|
+
## Pattern: Keys In A Database Instead Of A Bucket
|
|
415
|
+
|
|
416
|
+
`signingKeys` is a convenience over one particular storage shape. When keys live in rows, drive `generateKeyPair`/`importKeyPair` directly — they are the whole contract.
|
|
417
|
+
|
|
418
|
+
```typescript
|
|
419
|
+
let serialized = await JWK.generateKeyPair(JWK.Algorithm.ES256);
|
|
420
|
+
|
|
421
|
+
await db.create(table, {
|
|
422
|
+
id: serialized.id,
|
|
423
|
+
public_key: serialized.publicKey,
|
|
424
|
+
private_key: serialized.privateKey,
|
|
425
|
+
created_at: new Date(serialized.created).toISOString(),
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
let pair = await JWK.importKeyPair(serialized);
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
Importing is not free — cache the resulting `KeyPair[]` for the life of a request at minimum, and key that cache per tenant if one isolate serves several.
|
|
432
|
+
|
|
433
|
+
## Related Packages
|
|
434
|
+
|
|
435
|
+
- [`@sdxc/crypto`](/packages/crypto) - WebCrypto primitives for the secrets that are not tokens: password hashing, HMAC, sealed storage
|
|
436
|
+
- [`@sdxc/oidc-provider`](/packages/oidc-provider) - OIDC/OAuth2 provider engine, whose token value objects are built on `JWT`
|
|
437
|
+
|
|
438
|
+
## Tips
|
|
439
|
+
|
|
440
|
+
1. **Subclass per kind of token** - one class per token type, with an accessor for every claim it guarantees, is what keeps claim names out of handlers.
|
|
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.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The signature algorithms tokens are signed with and verified against, one
|
|
3
|
+
* export each, kept in a module of its own so a caller that needs only the
|
|
4
|
+
* names carries nothing else to read them.
|
|
5
|
+
*
|
|
6
|
+
* Import the whole set as a namespace, or a single name on its own:
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* import * as Algorithm from "@sdxc/jwt/algorithm";
|
|
10
|
+
* Object.values(Algorithm);
|
|
11
|
+
* @example
|
|
12
|
+
* import { ES256 } from "@sdxc/jwt/algorithm";
|
|
13
|
+
*
|
|
14
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
15
|
+
* @copyright Sergio Xalambrí 2026
|
|
16
|
+
*/
|
|
17
|
+
/** Issued here, and the only algorithm this repository's own tokens carry. */
|
|
18
|
+
export declare const ES256 = "ES256";
|
|
19
|
+
/** Verifies tokens from upstream identity providers. */
|
|
20
|
+
export declare const RS256 = "RS256";
|
|
21
|
+
/**
|
|
22
|
+
* Derives its nonce from the key and message, keeping the ECDSA nonce-reuse
|
|
23
|
+
* leak that exposes a private key out of reach.
|
|
24
|
+
*/
|
|
25
|
+
export declare const EdDSA = "EdDSA";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The signature algorithms tokens are signed with and verified against, one
|
|
3
|
+
* export each, kept in a module of its own so a caller that needs only the
|
|
4
|
+
* names carries nothing else to read them.
|
|
5
|
+
*
|
|
6
|
+
* Import the whole set as a namespace, or a single name on its own:
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* import * as Algorithm from "@sdxc/jwt/algorithm";
|
|
10
|
+
* Object.values(Algorithm);
|
|
11
|
+
* @example
|
|
12
|
+
* import { ES256 } from "@sdxc/jwt/algorithm";
|
|
13
|
+
*
|
|
14
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
15
|
+
* @copyright Sergio Xalambrí 2026
|
|
16
|
+
*/
|
|
17
|
+
/** Issued here, and the only algorithm this repository's own tokens carry. */
|
|
18
|
+
export const ES256 = "ES256";
|
|
19
|
+
/** Verifies tokens from upstream identity providers. */
|
|
20
|
+
export const RS256 = "RS256";
|
|
21
|
+
/**
|
|
22
|
+
* Derives its nonce from the key and message, keeping the ECDSA nonce-reuse
|
|
23
|
+
* leak that exposes a private key out of reach.
|
|
24
|
+
*/
|
|
25
|
+
export const EdDSA = "EdDSA";
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public surface of `@sdxc/jwt`: the `JWT` payload class, the `JWK` key namespace,
|
|
3
|
+
* and the storage contract signing-key rotation is written against.
|
|
4
|
+
*
|
|
5
|
+
* The claim parser behind `JWT`'s accessors stays internal, existing solely for
|
|
6
|
+
* token classes to read their own claims through.
|
|
7
|
+
*
|
|
8
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
9
|
+
* @copyright Sergio Xalambrí 2026
|
|
10
|
+
*/
|
|
11
|
+
export type { KeyStorage, KeyStorageListOptions, KeyStorageListResult } from "./key-storage.js";
|
|
12
|
+
export { JWK } from "./jwk.js";
|
|
13
|
+
export { JWT } from "./jwt.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public surface of `@sdxc/jwt`: the `JWT` payload class, the `JWK` key namespace,
|
|
3
|
+
* and the storage contract signing-key rotation is written against.
|
|
4
|
+
*
|
|
5
|
+
* The claim parser behind `JWT`'s accessors stays internal, existing solely for
|
|
6
|
+
* token classes to read their own claims through.
|
|
7
|
+
*
|
|
8
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
9
|
+
* @copyright Sergio Xalambrí 2026
|
|
10
|
+
*/
|
|
11
|
+
export { JWK } from "./jwk.js";
|
|
12
|
+
export { JWT } from "./jwt.js";
|