@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/dist/jwt.js ADDED
@@ -0,0 +1,322 @@
1
+ /**
2
+ * A JWT payload as an object with typed accessors, meant to be subclassed.
3
+ *
4
+ * A decoded token is a bag of `unknown`, and reading claims straight off it spreads
5
+ * the same string literals and the same type checks across every call site. `JWT`
6
+ * gives each kind of token a class instead: the registered claims arrive as getters,
7
+ * an application adds its own on top through `this.parser`, and signing, verifying,
8
+ * and decoding all produce an instance of that class.
9
+ *
10
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
11
+ * @copyright Sergio Xalambrí 2026
12
+ */
13
+ import { toSeconds } from "@sdxc/duration";
14
+ import * as jose from "jose";
15
+ import { ObjectParser } from "./lib/parser.js";
16
+ /** Milliseconds in a second, for converting between epoch claims and `Date`. */
17
+ const MS_PER_SECOND = 1000;
18
+ /** Claims that may be written as a length of time from now. */
19
+ const RELATIVE_CLAIMS = ["exp", "iat", "nbf"];
20
+ /**
21
+ * Resolves any claim written as a length of time into the instant it names.
22
+ * A duration is measured from now, so `exp: "1h"` is an hour from now, and a
23
+ * number stays seconds since the epoch; the claim set copies only when needed.
24
+ *
25
+ * @param payload - The claims as written.
26
+ * @returns The claims with every time claim as seconds since the epoch.
27
+ */
28
+ function resolveRelativeClaims(payload) {
29
+ let relative = RELATIVE_CLAIMS.filter((claim) => typeof payload[claim] === "string");
30
+ if (relative.length === 0)
31
+ return payload;
32
+ let resolved = { ...payload };
33
+ let now = Math.floor(Date.now() / MS_PER_SECOND);
34
+ for (let claim of relative)
35
+ resolved[claim] = now + toSeconds(payload[claim]);
36
+ return resolved;
37
+ }
38
+ /**
39
+ * A JWT payload, with the registered claims exposed as typed accessors.
40
+ *
41
+ * Subclass it to add accessors for claims beyond the registered set; an
42
+ * instance proxies to `payload`, so an accessor-less claim still reads by name.
43
+ *
44
+ * @example
45
+ * class IdToken extends JWT {
46
+ * override get subject() {
47
+ * return this.parser.string("sub"); // required here, so `string` not `string | null`
48
+ * }
49
+ *
50
+ * get email() {
51
+ * return this.parser.string("email");
52
+ * }
53
+ * }
54
+ */
55
+ export class JWT {
56
+ /** The raw claim set, readable whole for callers that want the payload itself. */
57
+ payload;
58
+ /** Type-checked reads over `payload`, for the accessors a subclass defines. */
59
+ parser;
60
+ /**
61
+ * Wraps a claim set in a proxy: a claim with no accessor still reads through
62
+ * to the payload, and assigning an unknown property lands there too, so a
63
+ * missing claim answers `null` and callers can test for it directly.
64
+ *
65
+ * @param payload - The claims, defaulting to an empty set so a token can be built
66
+ * up through the setters.
67
+ * @example
68
+ * let jwt = new JWT({ sub: "user-123" });
69
+ * jwt.subject; // "user-123"
70
+ */
71
+ constructor(payload = {}) {
72
+ this.payload = resolveRelativeClaims(payload);
73
+ this.parser = new ObjectParser(this.payload);
74
+ return new Proxy(this, {
75
+ get(self, prop) {
76
+ if (prop in self)
77
+ return Reflect.get(self, prop);
78
+ if (typeof prop === "string" && self.parser.has(prop))
79
+ return self.parser.get(prop);
80
+ return null;
81
+ },
82
+ set(self, prop, value) {
83
+ if (prop in self)
84
+ return Reflect.set(self, prop, value);
85
+ if (typeof prop === "string") {
86
+ self.payload[prop] = value;
87
+ return true;
88
+ }
89
+ return Reflect.set(self, prop, value);
90
+ },
91
+ });
92
+ }
93
+ /**
94
+ * The `aud` claim: who the token is for.
95
+ *
96
+ * @returns One audience, several, or `null` when the claim is absent or malformed.
97
+ */
98
+ get audience() {
99
+ if (!this.parser.has("aud"))
100
+ return null;
101
+ let value = this.parser.get("aud");
102
+ if (typeof value === "string")
103
+ return value;
104
+ if (Array.isArray(value))
105
+ return value;
106
+ return null;
107
+ }
108
+ /** @param value - The audience to set, or `null` to drop the claim. */
109
+ set audience(value) {
110
+ this.payload.aud = value ?? undefined;
111
+ }
112
+ /**
113
+ * The `exp` claim, as stored.
114
+ *
115
+ * @returns The raw claim value, or `null` when the token does not expire.
116
+ */
117
+ get expirationTime() {
118
+ if (this.parser.has("exp"))
119
+ return this.parser.number("exp");
120
+ return null;
121
+ }
122
+ /** @param value - The expiration to set, or `null` to drop the claim. */
123
+ set expirationTime(value) {
124
+ this.payload.exp = value ?? undefined;
125
+ }
126
+ /**
127
+ * How many seconds are left before the token expires.
128
+ *
129
+ * Counted in the seconds RFC 7519 stores `exp` in, so the number is a length of
130
+ * time and goes negative once the token is past its expiry.
131
+ *
132
+ * @returns The seconds until `exp`, or `null` when the claim is absent.
133
+ */
134
+ get expiresIn() {
135
+ if (this.expirationTime === null)
136
+ return null;
137
+ return this.expirationTime - Math.floor(Date.now() / MS_PER_SECOND);
138
+ }
139
+ /**
140
+ * The expiration as a `Date`.
141
+ *
142
+ * @returns The expiry, or `null` when the token does not expire.
143
+ */
144
+ get expiresAt() {
145
+ if (this.expirationTime === null)
146
+ return null;
147
+ return new Date(this.expirationTime * MS_PER_SECOND);
148
+ }
149
+ /**
150
+ * Whether the token is past its expiry, by this class's reading of `exp`.
151
+ *
152
+ * A convenience for display and for cache decisions. `JWT.verify` is what enforces
153
+ * `exp`, against the units the RFC defines.
154
+ *
155
+ * @returns `false` for a token with no `exp`, since nothing says it has expired.
156
+ */
157
+ get expired() {
158
+ if (this.expiresIn === null)
159
+ return false;
160
+ return this.expiresIn <= 0;
161
+ }
162
+ /**
163
+ * The `iat` claim: when the token was issued.
164
+ *
165
+ * @returns The issuance time, or `null` when the claim is absent.
166
+ */
167
+ get issuedAt() {
168
+ if (this.parser.has("iat"))
169
+ return new Date(this.parser.number("iat"));
170
+ return null;
171
+ }
172
+ /** @param value - The issuance time to set, or `null` to drop the claim. */
173
+ set issuedAt(value) {
174
+ this.payload.iat = value ? Math.floor(value.getTime() / MS_PER_SECOND) : undefined;
175
+ }
176
+ /**
177
+ * The `iss` claim: who issued the token.
178
+ *
179
+ * @returns The issuer, or `null` when the claim is absent.
180
+ */
181
+ get issuer() {
182
+ if (this.parser.has("iss"))
183
+ return this.parser.string("iss");
184
+ return null;
185
+ }
186
+ /** @param value - The issuer to set, or `null` to drop the claim. */
187
+ set issuer(value) {
188
+ this.payload.iss = value ?? undefined;
189
+ }
190
+ /**
191
+ * The `jti` claim: the token's unique identifier.
192
+ *
193
+ * @returns The identifier, or `null` when the claim is absent.
194
+ */
195
+ get id() {
196
+ if (this.parser.has("jti"))
197
+ return this.parser.string("jti");
198
+ return null;
199
+ }
200
+ /** @param value - The identifier to set, or `null` to drop the claim. */
201
+ set id(value) {
202
+ this.payload.jti = value ?? undefined;
203
+ }
204
+ /**
205
+ * The `nbf` claim: when the token starts being valid.
206
+ *
207
+ * @returns The start of validity, or `null` when the claim is absent.
208
+ */
209
+ get notBefore() {
210
+ if (this.parser.has("nbf"))
211
+ return new Date(this.parser.number("nbf"));
212
+ return null;
213
+ }
214
+ /** @param value - The start of validity to set, or `null` to drop the claim. */
215
+ set notBefore(value) {
216
+ this.payload.nbf = value ? Math.floor(value.getTime() / MS_PER_SECOND) : undefined;
217
+ }
218
+ /**
219
+ * The `sub` claim: who the token is about.
220
+ *
221
+ * @returns The subject, or `null` when the claim is absent.
222
+ */
223
+ get subject() {
224
+ if (this.parser.has("sub"))
225
+ return this.parser.string("sub");
226
+ return null;
227
+ }
228
+ /** @param value - The subject to set, or `null` to drop the claim. */
229
+ set subject(value) {
230
+ this.payload.sub = value ?? undefined;
231
+ }
232
+ /**
233
+ * Signs this token.
234
+ *
235
+ * @param algorithm - Algorithm to sign with; also selects the key.
236
+ * @param jwks - Candidate keys, newest first.
237
+ * @returns The compact-serialized token.
238
+ * @example
239
+ * let signed = await token.sign(JWK.Algorithm.ES256, await JWK.signingKeys(storage));
240
+ */
241
+ sign(algorithm, jwks) {
242
+ return JWT.sign(this, algorithm, jwks);
243
+ }
244
+ /**
245
+ * The claims a subclass exposes, as a plain object.
246
+ *
247
+ * Reads the getters declared directly on the instance's own prototype, so what
248
+ * comes out is the subclass's view of the token: the claims it models.
249
+ *
250
+ * @returns Each own getter's name mapped to its value.
251
+ */
252
+ toJSON() {
253
+ let descriptors = Object.getOwnPropertyDescriptors(Object.getPrototypeOf(this));
254
+ let keys = Object.keys(descriptors).filter((key) => typeof descriptors[key]?.get === "function");
255
+ return keys.reduce((acc, key) => {
256
+ acc[key] = this[key];
257
+ return acc;
258
+ }, {});
259
+ }
260
+ /**
261
+ * Signs a token with the first key matching the algorithm.
262
+ *
263
+ * The keys arrive newest first, so the newest signs while older ones stay
264
+ * published to verify what they already signed, keyed by the `kid` header.
265
+ *
266
+ * @param jwt - The token to sign.
267
+ * @param algorithm - Algorithm to sign with; also selects the key.
268
+ * @param jwks - Candidate keys, newest first.
269
+ * @returns The compact-serialized token.
270
+ * @throws When no key in the set was generated for that algorithm.
271
+ */
272
+ static sign(jwt, algorithm, jwks) {
273
+ let key = jwks.find((candidate) => candidate.alg === algorithm);
274
+ if (!key)
275
+ throw new Error(`No key available to sign JWT with algorithm ${algorithm}`);
276
+ return new jose.SignJWT(jwt.payload)
277
+ .setProtectedHeader({ alg: algorithm, typ: "JWT", kid: key.id })
278
+ .sign(key.private);
279
+ }
280
+ /**
281
+ * Verifies a token and returns it as an instance of the class this was called on.
282
+ *
283
+ * The key is picked by the token's `kid`, letting a retired key keep verifying
284
+ * what it already signed through a rotation; `algorithms` pins accepted ones.
285
+ *
286
+ * @param token - The compact-serialized token.
287
+ * @param jwks - The keys themselves, or a resolver from `JWK.importLocal` /
288
+ * `JWK.importRemote`. Keys given directly are wrapped into a local key set so
289
+ * they go through the same per-token `kid` selection a resolver would apply.
290
+ * @param options - Expected issuer, audience, algorithms, clock tolerance, and so on.
291
+ * @returns The verified token.
292
+ * @throws When the set offers no key for the token, or when a check on it fails.
293
+ * @example
294
+ * let idToken = await IdToken.verify(raw, keys, {
295
+ * issuer,
296
+ * audience: clientId,
297
+ * algorithms: [JWK.Algorithm.ES256],
298
+ * });
299
+ */
300
+ static async verify(token, jwks, options) {
301
+ if (Array.isArray(jwks) && jwks.length === 0) {
302
+ throw new Error("No key available to verify JWT");
303
+ }
304
+ let key = Array.isArray(jwks)
305
+ ? jose.createLocalJWKSet({ keys: jwks.map((candidate) => candidate.jwk) })
306
+ : jwks;
307
+ let result = await jose.jwtVerify(token, key, options);
308
+ return new this(result.payload);
309
+ }
310
+ /**
311
+ * Reads a token's claims as the token presents them, prior to verification.
312
+ *
313
+ * Use it to decide how to verify a token — reading `iss` to pick a JWKS, for
314
+ * instance — and let `verify` be what every decision about the request rests on.
315
+ *
316
+ * @param token - The compact-serialized token.
317
+ * @returns The decoded token, as an instance of the class this was called on.
318
+ */
319
+ static decode(token) {
320
+ return new this(jose.decodeJwt(token));
321
+ }
322
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The storage contract `JWK.signingKeys` needs, and nothing more.
3
+ *
4
+ * Signing keys have to outlive the isolate that generated them and be readable by
5
+ * every isolate that issues tokens, so they live in a bucket rather than in memory.
6
+ * This declares only the three operations that requires — read one, write one, page
7
+ * through them — so any object store can back it without this package taking on a
8
+ * dependency on a particular storage client.
9
+ *
10
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
11
+ * @copyright Sergio Xalambrí 2026
12
+ */
13
+ /** Paging and filtering options for a {@link KeyStorage} listing. */
14
+ export interface KeyStorageListOptions {
15
+ /** Opaque continuation token from a previous listing. */
16
+ cursor?: string;
17
+ /** Maximum number of entries to return in this page. */
18
+ limit?: number;
19
+ /** Only return entries whose key starts with this string. */
20
+ prefix?: string;
21
+ }
22
+ /** One page of a {@link KeyStorage} listing. */
23
+ export interface KeyStorageListResult {
24
+ /** Continuation token, absent once the listing is exhausted. */
25
+ cursor?: string;
26
+ /** The entries in this page, keys only. */
27
+ files: {
28
+ key: string;
29
+ }[];
30
+ }
31
+ /**
32
+ * A key/value store of `File` objects, narrowed to what signing-key rotation uses.
33
+ *
34
+ * A key, once written, stays kept, so every token it signed stays
35
+ * verifiable; methods may be sync or a promise, fitting tests and production.
36
+ */
37
+ export interface KeyStorage {
38
+ /**
39
+ * Reads the file stored under a key.
40
+ *
41
+ * @param key - The storage key to read.
42
+ * @returns The file, or `null` when nothing is stored under that key.
43
+ */
44
+ get(key: string): File | null | Promise<File | null>;
45
+ /**
46
+ * Pages through the stored keys.
47
+ *
48
+ * @param options - Prefix, page size, and continuation token.
49
+ * @returns One page of entries, plus a cursor when more remain.
50
+ */
51
+ list(options?: KeyStorageListOptions): KeyStorageListResult | Promise<KeyStorageListResult>;
52
+ /**
53
+ * Writes a file under a key, replacing anything already there.
54
+ *
55
+ * @param key - The storage key to write to.
56
+ * @param file - The file to store.
57
+ */
58
+ set(key: string, file: File): void | Promise<void>;
59
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The storage contract `JWK.signingKeys` needs, and nothing more.
3
+ *
4
+ * Signing keys have to outlive the isolate that generated them and be readable by
5
+ * every isolate that issues tokens, so they live in a bucket rather than in memory.
6
+ * This declares only the three operations that requires — read one, write one, page
7
+ * through them — so any object store can back it without this package taking on a
8
+ * dependency on a particular storage client.
9
+ *
10
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
11
+ * @copyright Sergio Xalambrí 2026
12
+ */
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Type-checked reads over a decoded JWT claim set.
3
+ *
4
+ * A JWT payload arrives as `unknown`-shaped JSON, so every claim a token class
5
+ * exposes has to be both looked up and type-checked before it can be returned.
6
+ * This module is the one place that does it, and the errors it raises name the
7
+ * claim and the type mismatch so a malformed token is diagnosable from the log
8
+ * line alone.
9
+ *
10
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
11
+ * @copyright Sergio Xalambrí 2026
12
+ */
13
+ /**
14
+ * Base class for every failure this parser raises.
15
+ *
16
+ * A single base lets a caller distinguish malformed claims from a genuine bug
17
+ * with one `instanceof` check; subclasses still tell missing from mistyped.
18
+ */
19
+ export declare class ParserError extends Error {
20
+ name: string;
21
+ }
22
+ /** Raised when a claim a token class requires is absent from the payload. */
23
+ export declare class MissingKeyError extends ParserError {
24
+ name: string;
25
+ /**
26
+ * Builds the error for an absent claim.
27
+ *
28
+ * @param key - Name of the claim that was not present.
29
+ */
30
+ constructor(key: string);
31
+ }
32
+ /** Raised when a claim is present but holds a value of the wrong JSON type. */
33
+ export declare class InvalidTypeError extends ParserError {
34
+ name: string;
35
+ /**
36
+ * Builds the error for a claim of an unexpected type.
37
+ *
38
+ * @param key - Name of the claim that was read.
39
+ * @param expected - Type the caller asked for.
40
+ * @param actual - Type the payload actually carried.
41
+ */
42
+ constructor(key: string, expected: string, actual: string);
43
+ }
44
+ /**
45
+ * Reads values out of a plain object, checking the type of each one.
46
+ *
47
+ * Throws on a mismatch, so a `JWT` subclass's getters can return the claim's
48
+ * own type directly.
49
+ *
50
+ * @example
51
+ * get email(): string {
52
+ * return this.parser.string("email");
53
+ * }
54
+ */
55
+ export declare class ObjectParser {
56
+ #private;
57
+ /**
58
+ * Wraps an object for type-checked reads.
59
+ *
60
+ * @param value - The decoded payload to read from.
61
+ * @throws {InvalidTypeError} When the value is not a non-null object.
62
+ */
63
+ constructor(value: unknown);
64
+ /**
65
+ * Reports whether a key is present, including when it holds `null`.
66
+ *
67
+ * This is the guard an optional claim needs, since every typed read below throws
68
+ * on a key that is not there.
69
+ *
70
+ * @param key - Claim name to look for.
71
+ * @returns Whether the key exists on the object.
72
+ * @example
73
+ * if (this.parser.has("nonce")) return this.parser.string("nonce");
74
+ */
75
+ has(key: string): boolean;
76
+ /**
77
+ * Reads a key exactly as stored, leaving the type check to the caller.
78
+ *
79
+ * @param key - Claim name to read.
80
+ * @returns The raw value, which the typed readers narrow.
81
+ * @throws {MissingKeyError} When the key is not present.
82
+ */
83
+ get(key: string): unknown;
84
+ /**
85
+ * Reads a key as a string.
86
+ *
87
+ * @param key - Claim name to read.
88
+ * @returns The string value.
89
+ * @throws {MissingKeyError} When the key is not present.
90
+ * @throws {InvalidTypeError} When the value is not a string.
91
+ * @example
92
+ * this.parser.string("sub"); // "user-123"
93
+ */
94
+ string(key: string): string;
95
+ /**
96
+ * Reads a key as a number.
97
+ *
98
+ * Time claims (`exp`, `iat`, `nbf`) are numbers in seconds, so this is what the
99
+ * date-shaped accessors are built on.
100
+ *
101
+ * @param key - Claim name to read.
102
+ * @returns The number value.
103
+ * @throws {MissingKeyError} When the key is not present.
104
+ * @throws {InvalidTypeError} When the value is not a number.
105
+ */
106
+ number(key: string): number;
107
+ /**
108
+ * Reads a key as a boolean.
109
+ *
110
+ * Strict: only an actual boolean passes, so a stringly-typed `email_verified`
111
+ * claim from an identity provider surfaces as a type error.
112
+ *
113
+ * @param key - Claim name to read.
114
+ * @returns The boolean value.
115
+ * @throws {MissingKeyError} When the key is not present.
116
+ * @throws {InvalidTypeError} When the value is not a boolean.
117
+ */
118
+ boolean(key: string): boolean;
119
+ /**
120
+ * Reads a key as a nested object, wrapped for further type-checked reads.
121
+ *
122
+ * @param key - Claim name to read.
123
+ * @returns A parser over the nested object.
124
+ * @throws {MissingKeyError} When the key is not present.
125
+ * @throws {InvalidTypeError} When the value is not a non-null object.
126
+ * @example
127
+ * this.parser.object("events").has("http://schemas.openid.net/event/backchannel-logout");
128
+ */
129
+ object(key: string): ObjectParser;
130
+ /**
131
+ * Returns the wrapped object, so a claim set can be handed on whole.
132
+ *
133
+ * @returns The object this parser reads from.
134
+ */
135
+ valueOf(): object;
136
+ }