@formancy/data-server 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,689 @@
1
+ import { KeyObject, createPublicKey, createSecretKey } from "node:crypto";
2
+ import { open, readFile } from "node:fs/promises";
3
+ import { isAbsolute } from "node:path";
4
+ import { errors, exportJWK, importSPKI, jwtVerify } from "jose";
5
+ import { constants } from "node:fs";
6
+ //#region src/ed25519.ts
7
+ /**
8
+ * What an Ed25519 public key's 32 bytes are, as far as a verifier has to ask
9
+ * (0059): a point on the curve, and whether it is one of small order.
10
+ *
11
+ * createPublicKey imports every point of small order, and under such a key a
12
+ * signature nobody made verifies: R the identity and S zero verified 64
13
+ * messages of 64 under the identity, and 34 of 64 under the point of order 2
14
+ * (Node 22.22.1, 2026-10-13). A key A is of small order exactly when [8]A,
15
+ * its multiple by the curve's cofactor, is the identity, which is what this
16
+ * computes from the curve itself: no list of points is transcribed.
17
+ */
18
+ const P = 2n ** 255n - 19n;
19
+ function mod(value) {
20
+ const rest = value % P;
21
+ return rest < 0n ? rest + P : rest;
22
+ }
23
+ function power(base, exponent) {
24
+ let result = 1n;
25
+ let square = mod(base);
26
+ for (let rest = exponent; rest > 0n; rest >>= 1n) {
27
+ if ((rest & 1n) === 1n) result = result * square % P;
28
+ square = square * square % P;
29
+ }
30
+ return result;
31
+ }
32
+ /** d = -121665/121666 (RFC 8032 section 5.1). */
33
+ const D = mod(-121665n * power(121666n, P - 2n));
34
+ /** A square root of -1 modulo p (RFC 8032 section 5.1.3). */
35
+ const ROOT_OF_MINUS_ONE = power(2n, (P - 1n) / 4n);
36
+ /**
37
+ * The point an encoding names, or undefined when it names none (RFC 8032
38
+ * section 5.1.3). y is read modulo p, as OpenSSL reads it, where the RFC
39
+ * refuses y at or above p: such a key is what the server would verify with.
40
+ */
41
+ function decode(bytes) {
42
+ let y = 0n;
43
+ for (let index = 31; index >= 0; index--) y = y << 8n | BigInt(index === 31 ? bytes[index] & 127 : bytes[index]);
44
+ y = mod(y);
45
+ const sign = BigInt(bytes[31] >> 7);
46
+ const u = mod(y * y - 1n);
47
+ const v = mod(D * y * y + 1n);
48
+ let x = mod(u * power(v, 3n) * power(u * power(v, 7n), (P - 5n) / 8n));
49
+ const check = mod(v * x * x);
50
+ if (check === mod(-u)) x = mod(x * ROOT_OF_MINUS_ONE);
51
+ else if (check !== u) return void 0;
52
+ if (x === 0n && sign === 1n) return void 0;
53
+ if ((x & 1n) !== sign) x = P - x;
54
+ return {
55
+ x,
56
+ y
57
+ };
58
+ }
59
+ /** [8]A in projective coordinates, by doubling three times (a = -1; dbl-2008-hwcd), and whether it is the identity. */
60
+ function eightTimesIsIdentity({ x, y }) {
61
+ let [X, Y, Z] = [
62
+ x,
63
+ y,
64
+ 1n
65
+ ];
66
+ for (let doubling = 0; doubling < 3; doubling++) {
67
+ const A = mod(X * X);
68
+ const B = mod(Y * Y);
69
+ const C = mod(2n * Z * Z);
70
+ const E = mod((X + Y) * (X + Y) - A - B);
71
+ const G = mod(B - A);
72
+ const F = mod(G - C);
73
+ const H = mod(-A - B);
74
+ [X, Y, Z] = [
75
+ mod(E * F),
76
+ mod(G * H),
77
+ mod(F * G)
78
+ ];
79
+ }
80
+ return X === 0n && Y === Z;
81
+ }
82
+ /** What the 32 bytes of an Ed25519 public key are: a JWK's `x`, once createPublicKey has imported it, which refuses any other length. */
83
+ function ed25519Point(bytes) {
84
+ const point = decode(bytes);
85
+ if (point === void 0) return "no point";
86
+ return eightTimesIsIdentity(point) ? "small order" : "a point";
87
+ }
88
+ //#endregion
89
+ //#region src/identity-keys.ts
90
+ /**
91
+ * The keys a host identity token is verified with (0014, 0059): one shared
92
+ * secret, one public key, or a JSON Web Key Set read from a file, each of its
93
+ * keys selected by the kid a token names.
94
+ *
95
+ * A `KeySet` is opaque. Its constructor refuses every caller but the
96
+ * builders below, which check what they are given, so a set the verifier
97
+ * meets is always one that `readKeySet`, `KeySet.fromSecret` or
98
+ * `KeySet.fromPem` made: one algorithm, public keys or long secrets, every kid
99
+ * distinct.
100
+ *
101
+ * Nothing a sentence here says comes from the file. JSON.parse's own message
102
+ * quotes the text around the error, and the text may be a secret, so a file
103
+ * that does not parse gets one fixed sentence; every other sentence names a
104
+ * key by its kid, when that is in the alphabet below and could not be a
105
+ * secret, or by its place in the file, and names a member, never its value.
106
+ *
107
+ * The class, its prototype and every set are frozen, and a set is verified
108
+ * with only through `verifyWithKeySet`, which index.ts does not export: code
109
+ * that reaches the class can replace none of its methods, and no caller can
110
+ * verify under a set with looser options than identity.ts passes.
111
+ */
112
+ const KEY_SET_ALGORITHMS = [
113
+ "RS256",
114
+ "ES256",
115
+ "EdDSA",
116
+ "HS256"
117
+ ];
118
+ function isKeySetAlgorithm(value) {
119
+ return KEY_SET_ALGORITHMS.some((algorithm) => algorithm === value);
120
+ }
121
+ /** jose's floor for RS256, which it checks only when a token arrives; a set is refused for it when it is read. */
122
+ const MIN_RSA_BITS = 2048;
123
+ /**
124
+ * The least RSA public exponent a key in force may have: FIPS 186's floor for
125
+ * an RSA key pair is an odd e above 2^16. createPublicKey imports e = 1, under
126
+ * which a signature is its own message's padding and anyone signs any token.
127
+ */
128
+ const MIN_RSA_EXPONENT = 65537n;
129
+ /** A sentence saying why a set was refused. It holds no text of the file, so it may be logged. */
130
+ var KeySetRefusal = class extends Error {
131
+ name = "KeySetRefusal";
132
+ };
133
+ /**
134
+ * The alphabet of a kid that may be named in a sentence: base64 and base64url,
135
+ * and the punctuation providers use. Anchored, one class, so nothing in it
136
+ * backtracks.
137
+ */
138
+ const KID = /^[A-Za-z0-9._~+/=-]+$/;
139
+ /** The key types of a public key. A kid beside one is a name, often a 43-character thumbprint, as Keycloak's are. */
140
+ const PUBLIC_KEY_TYPES = [
141
+ "RSA",
142
+ "EC",
143
+ "OKP"
144
+ ];
145
+ /** As many base64 characters as a secret HS256 takes: 32 bytes are 43 of them. */
146
+ const SECRET_CHARACTERS = Math.ceil(256 / 6);
147
+ const BASE64_CHARACTER = /[A-Za-z0-9_+/-]/g;
148
+ /** Private key members (RFC 7518 section 6, and AKP's `priv`). Their presence in a verifier's file refuses the whole set. */
149
+ const PRIVATE_MEMBERS = [
150
+ "d",
151
+ "p",
152
+ "q",
153
+ "dp",
154
+ "dq",
155
+ "qi",
156
+ "oth",
157
+ "priv"
158
+ ];
159
+ /** What a key must be to verify each algorithm. */
160
+ const VERIFIES = {
161
+ RS256: {
162
+ kty: "RSA",
163
+ kind: "an RSA"
164
+ },
165
+ ES256: {
166
+ kty: "EC",
167
+ crv: "P-256",
168
+ kind: "an EC"
169
+ },
170
+ EdDSA: {
171
+ kty: "OKP",
172
+ crv: "Ed25519",
173
+ kind: "an Ed25519"
174
+ },
175
+ HS256: {
176
+ kty: "oct",
177
+ kind: "a secret"
178
+ }
179
+ };
180
+ /** Only the builders hold this, so only they can call the constructor. */
181
+ const BUILD = Symbol("KeySet builder");
182
+ function isEntry(value) {
183
+ return typeof value === "object" && value !== null && !Array.isArray(value);
184
+ }
185
+ /**
186
+ * Whether a kid could be key material: one beside a key that is not a public
187
+ * key, that is its own `k` or holds as many base64 characters as a secret.
188
+ * Every kid fits the alphabet a secret is written in, and a kid is logged --
189
+ * in a sentence, and on the planes and SIGHUP lines -- so a secret pasted
190
+ * where the kid belongs would reach the log through it.
191
+ */
192
+ function kidCouldBeSecret(entry) {
193
+ const kid = entry["kid"];
194
+ if (typeof kid !== "string" || PUBLIC_KEY_TYPES.includes(entry["kty"])) return false;
195
+ return kid === entry["k"] || (kid.match(BASE64_CHARACTER)?.length ?? 0) >= SECRET_CHARACTERS;
196
+ }
197
+ /** A key as a sentence names it: `"kid"` when its kid is in the alphabet and could not be a secret, otherwise `#n`, its place in the file from 1. */
198
+ function nameOf(entry, index) {
199
+ return typeof entry["kid"] === "string" && KID.test(entry["kid"]) && !kidCouldBeSecret(entry) ? entry["kid"] : `#${String(index + 1)}`;
200
+ }
201
+ function quoted(name) {
202
+ return name.startsWith("#") ? name : `"${name}"`;
203
+ }
204
+ /**
205
+ * Why a key cannot verify `algorithm`, or why its publisher says it is not for
206
+ * that, as the member that says so; undefined when it is in force. `use` is
207
+ * asked before `alg`, because what a key is for is what its publisher says
208
+ * first: Keycloak's encryption key says both.
209
+ */
210
+ function leftOutBecause(entry, algorithm) {
211
+ const wanted = VERIFIES[algorithm];
212
+ if (entry["kty"] !== wanted.kty) return "kty";
213
+ if (wanted.crv !== void 0 && entry["crv"] !== wanted.crv) return "crv";
214
+ if (entry["use"] !== void 0 && entry["use"] !== "sig") return "use";
215
+ const ops = entry["key_ops"];
216
+ if (ops !== void 0 && !(Array.isArray(ops) && ops.includes("verify"))) return "key_ops";
217
+ if (entry["alg"] !== void 0 && entry["alg"] !== algorithm) return "alg";
218
+ }
219
+ /**
220
+ * The key, imported now and synchronously, so a set that cannot verify is
221
+ * refused when it is read rather than at its first token: an off-curve point
222
+ * is refused by createPublicKey, where jose would refuse it only then, and an
223
+ * RSA modulus is measured from the imported key, because createPublicKey
224
+ * imports one of no bits at all (M4). A key under which anyone can sign --
225
+ * an RSA exponent of 1, an Ed25519 point of small order -- imports too, and is
226
+ * refused here.
227
+ */
228
+ function importKey(entry, algorithm, kid) {
229
+ const name = quoted(kid);
230
+ if (algorithm === "HS256") {
231
+ const k = entry["k"];
232
+ if (typeof k !== "string") throw new KeySetRefusal(`key ${name} has no "k"`);
233
+ const bytes = Buffer.from(k, "base64url");
234
+ if (bytes.toString("base64url") !== k) throw new KeySetRefusal(`key ${name}'s "k" is not base64url without padding`);
235
+ if (bytes.length < 32) throw new KeySetRefusal(`key ${name} is shorter than ${String(32)} bytes, which HS256 needs (0014)`);
236
+ return createSecretKey(bytes);
237
+ }
238
+ let key;
239
+ try {
240
+ key = createPublicKey({
241
+ key: entry,
242
+ format: "jwk"
243
+ });
244
+ } catch {
245
+ throw new KeySetRefusal(`key ${name} does not import as ${VERIFIES[algorithm].kind} public key`);
246
+ }
247
+ if (algorithm === "RS256") {
248
+ const details = key.asymmetricKeyDetails;
249
+ if ((details?.modulusLength ?? 0) < MIN_RSA_BITS) throw new KeySetRefusal(`key ${name} is an RSA key of fewer than ${String(MIN_RSA_BITS)} bits, which RS256 refuses`);
250
+ const exponent = details?.publicExponent ?? 0n;
251
+ if (exponent < MIN_RSA_EXPONENT || exponent % 2n === 0n) throw new KeySetRefusal(`key ${name}'s RSA public exponent is not an odd number of at least ${String(MIN_RSA_EXPONENT)}: under an exponent of 1 anyone signs any token`);
252
+ }
253
+ if (algorithm === "EdDSA") {
254
+ const point = ed25519Point(Buffer.from(String(entry["x"]), "base64url"));
255
+ if (point === "no point") throw new KeySetRefusal(`key ${name} does not import as ${VERIFIES[algorithm].kind} public key`);
256
+ if (point === "small order") throw new KeySetRefusal(`key ${name} is an Ed25519 point of small order, under which a signature nobody made verifies`);
257
+ }
258
+ return key;
259
+ }
260
+ function signatureFailed(error) {
261
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ERR_JWS_SIGNATURE_VERIFICATION_FAILED";
262
+ }
263
+ let branded;
264
+ let verifyUnder;
265
+ var KeySet = class KeySet {
266
+ #algorithm;
267
+ /** A set read from a file: its keys in force, in file order, each selected by its kid. */
268
+ #keys;
269
+ #leftOut;
270
+ /** A one-key setting's key, which ignores a token's kid as it always has. */
271
+ #single;
272
+ constructor(build, algorithm, keys, leftOut, single) {
273
+ if (build !== BUILD) throw new TypeError("a KeySet is made by readKeySet, KeySet.fromSecret or KeySet.fromPem");
274
+ this.#algorithm = algorithm;
275
+ this.#keys = keys;
276
+ this.#leftOut = leftOut;
277
+ this.#single = single;
278
+ Object.freeze(this);
279
+ }
280
+ static {
281
+ branded = (value) => typeof value === "object" && value !== null && #keys in value;
282
+ verifyUnder = (set, token, options) => set.#verify(token, options);
283
+ }
284
+ /**
285
+ * A JSON Web Key Set, `{"keys":[…]}`, read for one algorithm. Throws a
286
+ * `KeySetRefusal` for an algorithm not one of the four, a file that does
287
+ * not parse, an entry that is not an object, any key holding a private
288
+ * member, a key in force without a kid in the alphabet, with another's kid
289
+ * or with one that could be a secret, one that does not import, an RSA key
290
+ * under 2048 bits or with an exponent under 65537 or even, an Ed25519 point
291
+ * of small order, a secret under 32 bytes, and a file with no key in force.
292
+ * A key that cannot verify the algorithm is left out and named.
293
+ */
294
+ static read(text, algorithm) {
295
+ if (!isKeySetAlgorithm(algorithm)) throw new KeySetRefusal("the algorithm of a key set is RS256, ES256, EdDSA or HS256");
296
+ let document;
297
+ try {
298
+ document = JSON.parse(text);
299
+ } catch {
300
+ throw new KeySetRefusal("the identity key set file is not JSON");
301
+ }
302
+ const entries = isEntry(document) ? document["keys"] : void 0;
303
+ if (!Array.isArray(entries) || entries.length === 0) throw new KeySetRefusal("the identity key set is not an object with a non-empty \"keys\" array");
304
+ const checked = entries.map((entry, index) => {
305
+ if (!isEntry(entry)) throw new KeySetRefusal(`key #${String(index + 1)} of the identity key set is not an object`);
306
+ const member = PRIVATE_MEMBERS.find((candidate) => entry[candidate] !== void 0);
307
+ if (member !== void 0) throw new KeySetRefusal(`key ${quoted(nameOf(entry, index))} holds the private member "${member}": a verifier's file holds public keys only`);
308
+ return entry;
309
+ });
310
+ const inForce = [];
311
+ const leftOut = [];
312
+ for (const [index, entry] of checked.entries()) {
313
+ const because = leftOutBecause(entry, algorithm);
314
+ if (because !== void 0) {
315
+ leftOut.push({
316
+ key: nameOf(entry, index),
317
+ because
318
+ });
319
+ continue;
320
+ }
321
+ const kid = entry["kid"];
322
+ if (kid === void 0) throw new KeySetRefusal(`key #${String(index + 1)} has no "kid": a key in force is selected by it`);
323
+ if (typeof kid !== "string" || !KID.test(kid)) throw new KeySetRefusal(`key #${String(index + 1)}'s "kid" is not a non-empty string of A-Z, a-z, 0-9 and . _ ~ + / = -`);
324
+ if (kidCouldBeSecret(entry)) throw new KeySetRefusal(`key #${String(index + 1)}'s "kid" could be a secret -- it is its "k", or ${String(SECRET_CHARACTERS)} base64 characters or more -- and a kid is logged, so it is a name, never key material`);
325
+ if (inForce.some((other) => other.kid === kid)) throw new KeySetRefusal(`two keys in force are both "${kid}": a kid selects one key`);
326
+ inForce.push({
327
+ kid,
328
+ key: importKey(entry, algorithm, kid)
329
+ });
330
+ }
331
+ if (inForce.length === 0) throw new KeySetRefusal(`no key in the file can verify ${algorithm} (left out: ${leftOut.map((key) => `${key.key} (${key.because})`).join(", ")})`);
332
+ return new KeySet(BUILD, algorithm, inForce, leftOut, void 0);
333
+ }
334
+ /** `FORMANCY_DATA_IDENTITY_SECRET`'s key: HS256 over the secret's UTF-8 bytes, at least 32 of them. */
335
+ static fromSecret(secret) {
336
+ if (Buffer.byteLength(secret, "utf8") < 32) throw new KeySetRefusal(`the identity secret must be at least ${String(32)} bytes`);
337
+ return new KeySet(BUILD, "HS256", [], [], createSecretKey(Buffer.from(secret, "utf8")));
338
+ }
339
+ /** `FORMANCY_DATA_IDENTITY_PUBLIC_KEY_FILE`'s key: an SPKI PEM, imported by jose as it always was, for RS256, ES256 or EdDSA and never HS256. */
340
+ static async fromPem(pem, algorithm) {
341
+ if (algorithm !== "RS256" && algorithm !== "ES256" && algorithm !== "EdDSA") throw new KeySetRefusal("a public key verifies RS256, ES256 or EdDSA: HS256 is a shared secret, never a public key");
342
+ let key;
343
+ try {
344
+ key = await importSPKI(pem, algorithm);
345
+ } catch {
346
+ throw new KeySetRefusal(`the identity public key is not an SPKI PEM public key for ${algorithm}`);
347
+ }
348
+ return new KeySet(BUILD, algorithm, [], [], key);
349
+ }
350
+ /** Whether `value` is a set the builders made. A brand check, so neither a look-alike nor an object made from the prototype passes. */
351
+ static is(value) {
352
+ return branded(value);
353
+ }
354
+ get algorithm() {
355
+ return this.#algorithm;
356
+ }
357
+ /** The kids in force, in file order; none for a one-key setting, which ignores kids. */
358
+ get kids() {
359
+ return this.#keys.map((entry) => entry.kid);
360
+ }
361
+ get leftOut() {
362
+ return this.#leftOut;
363
+ }
364
+ /** The type of each key in force, `public` or `secret`: what a test can ask of a key without being handed it. */
365
+ get keyTypes() {
366
+ return this.#single === void 0 ? this.#keys.map((entry) => entry.key.type) : [this.#single.type];
367
+ }
368
+ /** What `next` adds, removes and replaces of this set's kids. A key is replaced when its kid stays and its key does not. */
369
+ compare(next) {
370
+ const before = new Map(this.#keys.map((entry) => [entry.kid, entry.key]));
371
+ const after = new Map(next.#keys.map((entry) => [entry.kid, entry.key]));
372
+ return {
373
+ added: [...after.keys()].filter((kid) => !before.has(kid)),
374
+ removed: [...before.keys()].filter((kid) => !after.has(kid)),
375
+ replaced: [...after].filter(([kid, key]) => before.has(kid) && !before.get(kid).equals(key)).map(([kid]) => kid)
376
+ };
377
+ }
378
+ /**
379
+ * A one-key setting's key as a key set file holding it under `kid`: what
380
+ * `identity-keys-cli.mjs convert` writes. The secret's key is the base64url
381
+ * of the bytes this set verifies with, not the secret's text, which is also
382
+ * valid base64url and another key (M2). Read back before it is returned.
383
+ * This is key material; nothing logs it.
384
+ */
385
+ async document(kid) {
386
+ const single = this.#single;
387
+ if (single === void 0) throw new KeySetRefusal("a set read from a file is a key set file already");
388
+ if (!KID.test(kid)) throw new KeySetRefusal("a kid is a non-empty string of A-Z, a-z, 0-9 and . _ ~ + / = -");
389
+ const { kty, ...material } = single instanceof KeyObject && single.type === "secret" ? {
390
+ kty: "oct",
391
+ k: single.export().toString("base64url")
392
+ } : await exportJWK(single);
393
+ const text = `${JSON.stringify({ keys: [{
394
+ kty,
395
+ kid,
396
+ alg: this.#algorithm,
397
+ ...material
398
+ }] }, null, 2)}\n`;
399
+ KeySet.read(text, this.#algorithm);
400
+ return text;
401
+ }
402
+ /**
403
+ * jwtVerify under this set, its algorithm pinned. A one-key setting's key is
404
+ * the key, as it always was. A set's key is chosen inside jwtVerify by its
405
+ * key resolver, which jose calls only after it has refused a malformed
406
+ * token or another algorithm, so those keep their codes (M5):
407
+ *
408
+ * - a kid that is a string selects that key and no other, and one the set
409
+ * does not hold is `ERR_JWKS_NO_MATCHING_KEY`, even when another key
410
+ * would verify;
411
+ * - no kid: each key in force in file order, moving on only past a
412
+ * signature that does not verify, as PostgREST does; any other refusal
413
+ * (expired, another audience, a missing claim) is the answer.
414
+ *
415
+ * No member of the token's header but its kid chooses a key: one that
416
+ * carries a `jwk`, or names a `jku` or `x5u`, is verified against the set
417
+ * alone (identity.test.ts).
418
+ */
419
+ async #verify(token, options) {
420
+ const pinned = {
421
+ ...options,
422
+ algorithms: [this.#algorithm]
423
+ };
424
+ if (this.#single !== void 0) return jwtVerify(token, this.#single, pinned);
425
+ const keys = this.#keys;
426
+ let kidless = false;
427
+ const select = (header) => {
428
+ const { kid } = header;
429
+ if (kid === void 0) {
430
+ kidless = true;
431
+ return keys[0].key;
432
+ }
433
+ if (typeof kid !== "string") throw new errors.JWSInvalid("the token's \"kid\" is not a string");
434
+ const selected = keys.find((entry) => entry.kid === kid);
435
+ if (selected === void 0) throw new errors.JWKSNoMatchingKey();
436
+ return selected.key;
437
+ };
438
+ try {
439
+ return await jwtVerify(token, select, pinned);
440
+ } catch (error) {
441
+ if (!kidless || !signatureFailed(error)) throw error;
442
+ }
443
+ for (const { key } of keys.slice(1)) try {
444
+ return await jwtVerify(token, key, pinned);
445
+ } catch (error) {
446
+ if (!signatureFailed(error)) throw error;
447
+ }
448
+ throw new errors.JWSSignatureVerificationFailed();
449
+ }
450
+ };
451
+ Object.freeze(KeySet.prototype);
452
+ Object.freeze(KeySet);
453
+ /** The one function that checks a key set file's text; see `KeySet.read`. */
454
+ function readKeySet(text, algorithm) {
455
+ return KeySet.read(text, algorithm);
456
+ }
457
+ /** Whether `value` is a set the builders made: identity.ts's check, which nothing outside this module can replace. */
458
+ function isKeySet(value) {
459
+ return branded(value);
460
+ }
461
+ /**
462
+ * jwtVerify under a set the builders made, its algorithm pinned (see
463
+ * `KeySet#verify`). identity.ts's one way to verify; index.ts does not export
464
+ * it, so an embedder cannot call it with looser options than identity.ts's.
465
+ */
466
+ function verifyWithKeySet(set, token, options) {
467
+ return verifyUnder(set, token, options);
468
+ }
469
+ //#endregion
470
+ //#region src/identity.ts
471
+ /** Why a token is refused when the verifier is handed something that is not a set the builders made. */
472
+ const NOT_A_KEY_SET = "the identity key set is not one readKeySet made";
473
+ /**
474
+ * The set a one-key setting verifies with: a secret's UTF-8 bytes, at least
475
+ * 32 of them, or the PEM as jose imports it. What `identity-keys-cli.mjs
476
+ * convert` writes is this set's key, so the two never differ.
477
+ */
478
+ async function oneKeySet(key) {
479
+ return key.kind === "secret" ? KeySet.fromSecret(key.secret) : KeySet.fromPem(key.pem, key.algorithm);
480
+ }
481
+ /**
482
+ * A verifier for the host application's tokens, built once at startup.
483
+ *
484
+ * Fails at construction on a configuration that would accept forgeries — a
485
+ * short secret, a key that does not parse, a key set no builder checked —
486
+ * rather than on the first request. The algorithm is pinned per key kind, and
487
+ * for a whole key set, so a token cannot choose `none` or swap an asymmetric
488
+ * key for a shared one.
489
+ *
490
+ * A key set's `keys` is asked once per token, so a set read again replaces
491
+ * the one in force between tokens, and a verification that has begun
492
+ * finishes on the set it began with.
493
+ */
494
+ async function createIdentityVerifier(options) {
495
+ let keys;
496
+ if (options.key.kind === "key-set") {
497
+ keys = options.key.keys;
498
+ if (!isKeySet(keys())) throw new Error(NOT_A_KEY_SET);
499
+ } else {
500
+ const set = await oneKeySet(options.key);
501
+ keys = () => set;
502
+ }
503
+ const rolesClaim = options.rolesClaim ?? "roles";
504
+ const tolerance = options.clockToleranceSeconds ?? 30;
505
+ return async (token) => {
506
+ let set;
507
+ try {
508
+ set = keys();
509
+ } catch {
510
+ set = void 0;
511
+ }
512
+ if (!isKeySet(set)) return {
513
+ ok: false,
514
+ reason: NOT_A_KEY_SET
515
+ };
516
+ let payload;
517
+ try {
518
+ ({payload} = await verifyWithKeySet(set, token, {
519
+ issuer: options.issuer,
520
+ audience: options.audience,
521
+ clockTolerance: tolerance,
522
+ requiredClaims: ["sub", "exp"]
523
+ }));
524
+ } catch (error) {
525
+ return {
526
+ ok: false,
527
+ reason: typeof error === "object" && error !== null && "code" in error ? String(error.code) : "ERR_JWT_INVALID"
528
+ };
529
+ }
530
+ const id = payload.sub;
531
+ if (typeof id !== "string" || id === "") return {
532
+ ok: false,
533
+ reason: "the token has no subject"
534
+ };
535
+ const roles = payload[rolesClaim] ?? [];
536
+ if (!Array.isArray(roles) || !roles.every((role) => typeof role === "string")) return {
537
+ ok: false,
538
+ reason: `the ${rolesClaim} claim is not a list of strings`
539
+ };
540
+ const attributes = {};
541
+ for (const [attribute, claim] of Object.entries(options.attributes)) {
542
+ const value = payload[claim];
543
+ if (value === void 0) continue;
544
+ if (typeof value === "string") attributes[attribute] = value;
545
+ else if (typeof value === "number" && Number.isSafeInteger(value)) attributes[attribute] = String(value);
546
+ else return {
547
+ ok: false,
548
+ reason: `the ${claim} claim is not a string or a whole number`
549
+ };
550
+ }
551
+ return {
552
+ ok: true,
553
+ identity: {
554
+ actor: {
555
+ id,
556
+ roles: [...roles]
557
+ },
558
+ attributes
559
+ }
560
+ };
561
+ };
562
+ }
563
+ //#endregion
564
+ //#region src/identity-file.ts
565
+ /**
566
+ * A key set file's text, read the way a read always finishes: opened without
567
+ * waiting for a writer, and refused unless it is a regular file. readFile on
568
+ * a FIFO waited for a writer, and a read again that never finished held every
569
+ * later one behind it, SIGTERM's shutdown too. A file on a network mount that
570
+ * stalls still stalls the read: nothing here can tell a slow server from a
571
+ * lost one.
572
+ */
573
+ async function readKeySetText(path) {
574
+ const handle = await open(path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
575
+ try {
576
+ if (!(await handle.stat()).isFile()) throw new KeySetRefusal(`the identity key set file ${path} is not a regular file`);
577
+ return await handle.readFile("utf8");
578
+ } finally {
579
+ await handle.close();
580
+ }
581
+ }
582
+ async function readSet(path, algorithm, read) {
583
+ let text;
584
+ try {
585
+ text = await read(path);
586
+ } catch (error) {
587
+ if (error instanceof KeySetRefusal) throw error;
588
+ throw new KeySetRefusal(`the identity key set file ${path} cannot be read`);
589
+ }
590
+ return readKeySet(text, algorithm);
591
+ }
592
+ /**
593
+ * The key set in `path`, read now -- a refused set throws, which stops the
594
+ * server at start -- and read again whenever `readAgain` is called: on SIGHUP
595
+ * in the running server (main.ts), and by an embedder wherever it chooses.
596
+ *
597
+ * A read that fails keeps the set in force, a key removed from the file
598
+ * included, because a typo or a half-written file must not refuse every
599
+ * token; the outcome says so, and the same file then stops the next start
600
+ * until it is fixed. `read` is injected for the tests; the server and the
601
+ * tool read through readKeySetText.
602
+ */
603
+ async function openKeySetFile(path, algorithm, read = readKeySetText) {
604
+ let current = await readSet(path, algorithm, read);
605
+ const readOnce = async () => {
606
+ let next;
607
+ try {
608
+ next = await readSet(path, algorithm, read);
609
+ } catch (error) {
610
+ return {
611
+ read: false,
612
+ kids: current.kids,
613
+ problem: error instanceof KeySetRefusal ? error.message : "the identity key set file could not be read as a key set"
614
+ };
615
+ }
616
+ const { added, removed, replaced } = current.compare(next);
617
+ current = next;
618
+ if (added.length === 0 && removed.length === 0 && replaced.length === 0) return {
619
+ read: true,
620
+ changed: false,
621
+ kids: next.kids,
622
+ leftOut: next.leftOut
623
+ };
624
+ return {
625
+ read: true,
626
+ changed: true,
627
+ kids: next.kids,
628
+ added,
629
+ removed,
630
+ replaced,
631
+ leftOut: next.leftOut
632
+ };
633
+ };
634
+ let queue = Promise.resolve();
635
+ return {
636
+ current: () => current,
637
+ readAgain: () => {
638
+ const outcome = queue.then(readOnce);
639
+ queue = outcome;
640
+ return outcome;
641
+ }
642
+ };
643
+ }
644
+ //#endregion
645
+ //#region src/secrets.ts
646
+ const DEFAULT_SOURCE = {
647
+ env: process.env,
648
+ readFile: (path) => readFile(path, "utf8")
649
+ };
650
+ const ENV_NAME = /^[A-Z_][A-Z0-9_]*$/;
651
+ /**
652
+ * The value behind a secret reference.
653
+ *
654
+ * Configuration names a secret, never contains one (plan section 3: "a
655
+ * server-side secret reference"). Two forms: `env:NAME`, read from the
656
+ * environment, and `file:/absolute/path`, read from a file — which is how
657
+ * Docker and Kubernetes hand secrets to a container.
658
+ *
659
+ * **No error message ever contains the reference's value**, only its form. The
660
+ * commonest mistake is pasting the secret itself where its reference belongs,
661
+ * and an error that echoed the input would print the secret into a log.
662
+ */
663
+ async function resolveSecret(reference, source = DEFAULT_SOURCE) {
664
+ if (reference.startsWith("env:")) {
665
+ const name = reference.slice(4);
666
+ if (!ENV_NAME.test(name)) throw new Error("an env: secret reference names a variable in upper case, digits and underscores");
667
+ const value = source.env[name];
668
+ if (value === void 0 || value === "") throw new Error(`the environment variable ${name} is unset or empty`);
669
+ return value;
670
+ }
671
+ if (reference.startsWith("file:")) {
672
+ const path = reference.slice(5);
673
+ if (!isAbsolute(path)) throw new Error("a file: secret reference needs an absolute path");
674
+ let text;
675
+ try {
676
+ text = await source.readFile(path);
677
+ } catch {
678
+ throw new Error(`the secret file ${path} cannot be read`);
679
+ }
680
+ const value = text.endsWith("\r\n") ? text.slice(0, -2) : text.endsWith("\n") ? text.slice(0, -1) : text;
681
+ if (value === "") throw new Error(`the secret file ${path} is empty`);
682
+ return value;
683
+ }
684
+ throw new Error("a secret reference is env:NAME or file:/absolute/path. If you pasted the secret itself here, move it into one of those instead.");
685
+ }
686
+ //#endregion
687
+ export { KeySet as a, readKeySet as c, oneKeySet as i, openKeySetFile as n, KeySetRefusal as o, createIdentityVerifier as r, isKeySetAlgorithm as s, resolveSecret as t };
688
+
689
+ //# sourceMappingURL=secrets-DhKHG-Y9.mjs.map