@sdxc/jwt 2026.10.2 → 2026.10.9
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 +21 -0
- package/dist/r2-key-storage.d.ts +48 -0
- package/dist/r2-key-storage.js +58 -0
- package/package.json +14 -4
package/README.md
CHANGED
|
@@ -10,6 +10,11 @@ npm add @sdxc/jwt
|
|
|
10
10
|
|
|
11
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.
|
|
12
12
|
|
|
13
|
+
Two entry points:
|
|
14
|
+
|
|
15
|
+
- `@sdxc/jwt` — `JWT`, the `JWK` namespace, and the `KeyStorage` contract.
|
|
16
|
+
- `@sdxc/jwt/r2` — `createR2KeyStorage`, the `KeyStorage` over a Cloudflare R2 bucket.
|
|
17
|
+
|
|
13
18
|
## Usage
|
|
14
19
|
|
|
15
20
|
### Describing A Kind Of Token
|
|
@@ -235,6 +240,22 @@ interface KeyStorageListResult {
|
|
|
235
240
|
|
|
236
241
|
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.
|
|
237
242
|
|
|
243
|
+
### `createR2KeyStorage(bucket: R2KeyBucket): KeyStorage`
|
|
244
|
+
|
|
245
|
+
From `@sdxc/jwt/r2`. Stores key files in a Cloudflare R2 bucket, the place a key outlives every isolate and every Worker issuing tokens for one issuer reads the same set.
|
|
246
|
+
|
|
247
|
+
```typescript
|
|
248
|
+
import { JWK } from "@sdxc/jwt";
|
|
249
|
+
import { createR2KeyStorage } from "@sdxc/jwt/r2";
|
|
250
|
+
import { env } from "cloudflare:workers";
|
|
251
|
+
|
|
252
|
+
let keys = await JWK.signingKeys(createR2KeyStorage(env.KEYS));
|
|
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.
|
|
258
|
+
|
|
238
259
|
## Rotation, And The Order To Roll It Out In
|
|
239
260
|
|
|
240
261
|
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:
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key storage over a Cloudflare R2 bucket, where signing keys outlive every
|
|
3
|
+
* isolate and every Worker issuing tokens for one issuer reads the same files.
|
|
4
|
+
* Each stored object carries the file's name and type, so a key reads back as the `File` written.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { KeyStorage, KeyStorageListOptions } from "./key-storage.js";
|
|
10
|
+
/** The HTTP metadata an object is written and read with. */
|
|
11
|
+
interface R2KeyHTTPMetadata {
|
|
12
|
+
contentType?: string;
|
|
13
|
+
}
|
|
14
|
+
/** A stored object as a read answers it. */
|
|
15
|
+
export interface R2KeyObject {
|
|
16
|
+
uploaded: Date;
|
|
17
|
+
httpMetadata?: R2KeyHTTPMetadata;
|
|
18
|
+
customMetadata?: Record<string, string>;
|
|
19
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The parts of an R2 bucket binding the storage uses, declared structurally so
|
|
23
|
+
* importing this module needs no platform types; an `R2Bucket` satisfies it.
|
|
24
|
+
*/
|
|
25
|
+
export interface R2KeyBucket {
|
|
26
|
+
get(key: string): Promise<R2KeyObject | null>;
|
|
27
|
+
list(options?: KeyStorageListOptions): Promise<{
|
|
28
|
+
objects: {
|
|
29
|
+
key: string;
|
|
30
|
+
}[];
|
|
31
|
+
truncated: boolean;
|
|
32
|
+
cursor?: string;
|
|
33
|
+
}>;
|
|
34
|
+
put(key: string, value: ArrayBuffer, options?: {
|
|
35
|
+
httpMetadata?: R2KeyHTTPMetadata;
|
|
36
|
+
customMetadata?: Record<string, string>;
|
|
37
|
+
}): Promise<unknown>;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Adapts an R2 bucket binding to the storage `JWK.signingKeys` reads and rotates
|
|
41
|
+
* keys through. The bucket is passed in, so the caller decides where keys live.
|
|
42
|
+
*
|
|
43
|
+
* @param bucket - The R2 bucket binding key files are stored in.
|
|
44
|
+
* @returns Storage that reads, writes, and pages through key files in that bucket.
|
|
45
|
+
* @example let keys = await JWK.signingKeys(createR2KeyStorage(env.KEYS));
|
|
46
|
+
*/
|
|
47
|
+
export declare function createR2KeyStorage(bucket: R2KeyBucket): KeyStorage;
|
|
48
|
+
export {};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key storage over a Cloudflare R2 bucket, where signing keys outlive every
|
|
3
|
+
* isolate and every Worker issuing tokens for one issuer reads the same files.
|
|
4
|
+
* Each stored object carries the file's name and type, so a key reads back as the `File` written.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Adapts an R2 bucket binding to the storage `JWK.signingKeys` reads and rotates
|
|
11
|
+
* keys through. The bucket is passed in, so the caller decides where keys live.
|
|
12
|
+
*
|
|
13
|
+
* @param bucket - The R2 bucket binding key files are stored in.
|
|
14
|
+
* @returns Storage that reads, writes, and pages through key files in that bucket.
|
|
15
|
+
* @example let keys = await JWK.signingKeys(createR2KeyStorage(env.KEYS));
|
|
16
|
+
*/
|
|
17
|
+
export function createR2KeyStorage(bucket) {
|
|
18
|
+
return {
|
|
19
|
+
/**
|
|
20
|
+
* A missing key comes back as null, so an object removed between a listing
|
|
21
|
+
* and its read reads the same as a key that was never there. The bytes are
|
|
22
|
+
* read fully into memory, which a key file of a few hundred bytes affords.
|
|
23
|
+
*/
|
|
24
|
+
async get(key) {
|
|
25
|
+
let object = await bucket.get(key);
|
|
26
|
+
if (!object)
|
|
27
|
+
return null;
|
|
28
|
+
let metadata = object.customMetadata;
|
|
29
|
+
return new File([await object.arrayBuffer()], metadata?.name ?? key, {
|
|
30
|
+
type: object.httpMetadata?.contentType ?? metadata?.type,
|
|
31
|
+
lastModified: object.uploaded.getTime(),
|
|
32
|
+
});
|
|
33
|
+
},
|
|
34
|
+
/**
|
|
35
|
+
* Carries R2's cursor only while the page is truncated, so a caller walking
|
|
36
|
+
* pages until the cursor is gone stops after the last one.
|
|
37
|
+
*/
|
|
38
|
+
async list(options) {
|
|
39
|
+
let result = await bucket.list({
|
|
40
|
+
cursor: options?.cursor,
|
|
41
|
+
limit: options?.limit,
|
|
42
|
+
prefix: options?.prefix,
|
|
43
|
+
});
|
|
44
|
+
return {
|
|
45
|
+
files: result.objects.map((object) => ({ key: object.key })),
|
|
46
|
+
cursor: result.truncated ? result.cursor : undefined,
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
/** Replaces whatever is stored under the key, recording the file's name and type. */
|
|
50
|
+
async set(key, file) {
|
|
51
|
+
let metadata = { name: file.name, type: file.type };
|
|
52
|
+
await bucket.put(key, await file.arrayBuffer(), {
|
|
53
|
+
httpMetadata: { contentType: file.type },
|
|
54
|
+
customMetadata: metadata,
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
package/package.json
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdxc/jwt",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.9",
|
|
4
4
|
"description": "JWT payload classes and the keys that sign them",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./dist/index.js",
|
|
9
|
-
"./algorithm": "./dist/algorithm.js"
|
|
9
|
+
"./algorithm": "./dist/algorithm.js",
|
|
10
|
+
"./r2": "./dist/r2-key-storage.js"
|
|
10
11
|
},
|
|
11
12
|
"dependencies": {
|
|
12
|
-
"@sdxc/duration": "2026.10.
|
|
13
|
+
"@sdxc/duration": "2026.10.6",
|
|
13
14
|
"jose": "^6.2.10"
|
|
14
15
|
},
|
|
15
|
-
"gitHead": "
|
|
16
|
+
"gitHead": "64fa732ed0a8ca8693a35aeef6345ae5be2534ef",
|
|
16
17
|
"publishConfig": {
|
|
17
18
|
"access": "public"
|
|
18
19
|
},
|
|
@@ -20,5 +21,14 @@
|
|
|
20
21
|
"type": "git",
|
|
21
22
|
"url": "git+https://github.com/sergiodxa/monorepo.git",
|
|
22
23
|
"directory": "packages/jwt"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://sdxc.sergiodxa.com/api/jwt",
|
|
26
|
+
"author": {
|
|
27
|
+
"name": "Sergio Xalambrí",
|
|
28
|
+
"url": "https://sergiodxa.com"
|
|
29
|
+
},
|
|
30
|
+
"funding": {
|
|
31
|
+
"type": "github",
|
|
32
|
+
"url": "https://github.com/sponsors/sergiodxa"
|
|
23
33
|
}
|
|
24
34
|
}
|