@kleora-io/node 0.0.0 → 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.
package/dist/index.cjs ADDED
@@ -0,0 +1,1057 @@
1
+ 'use strict';
2
+
3
+ var jose = require('jose');
4
+
5
+ // src/errors.ts
6
+ var KleoraError = class extends Error {
7
+ code;
8
+ constructor(code, message, options) {
9
+ super(message, options);
10
+ this.name = "KleoraError";
11
+ this.code = code;
12
+ }
13
+ };
14
+ var TokenError = class extends KleoraError {
15
+ constructor(code, message, options) {
16
+ super(code, message, options);
17
+ this.name = "TokenError";
18
+ }
19
+ };
20
+ var LoginRequired = class extends KleoraError {
21
+ constructor(message) {
22
+ super("login_required", message);
23
+ this.name = "LoginRequired";
24
+ }
25
+ };
26
+ var EnvironmentMismatch = class extends KleoraError {
27
+ constructor(message) {
28
+ super("environment_mismatch", message);
29
+ this.name = "EnvironmentMismatch";
30
+ }
31
+ };
32
+ var ApiError = class extends KleoraError {
33
+ type;
34
+ status;
35
+ detail;
36
+ errors;
37
+ problem;
38
+ /**
39
+ * The correlation id of the response that failed.
40
+ *
41
+ * The one field a support conversation needs: it is what ties the error a
42
+ * customer pasted to the request in our logs. Read from the document's
43
+ * `request_id` first and from the `X-Request-Id` response header otherwise,
44
+ * because the header is all there is when the body is not a problem document
45
+ * — an intermediary's HTML 502 — or when the deployment answering predates
46
+ * the member. `undefined` only when neither carried one.
47
+ */
48
+ requestId;
49
+ constructor(type, status, detail, errors, problem2, requestId) {
50
+ super("api_error", detail ?? type);
51
+ this.name = "ApiError";
52
+ this.type = type;
53
+ this.status = status;
54
+ this.detail = detail;
55
+ this.errors = errors;
56
+ this.problem = problem2;
57
+ this.requestId = requestId;
58
+ }
59
+ };
60
+
61
+ // src/environment.ts
62
+ var SANDBOX_SUFFIX = "-sandbox";
63
+ var PORT_SUFFIX = /:\d+$/;
64
+ function classify(host) {
65
+ const first = host.toLowerCase().replace(PORT_SUFFIX, "").split(".")[0] ?? "";
66
+ return first.length > SANDBOX_SUFFIX.length && first.endsWith(SANDBOX_SUFFIX) ? "sandbox" : "production";
67
+ }
68
+ function parseAbsolute(name, value) {
69
+ const complaint = `${name} must be an absolute http(s) URL, got '${value}'`;
70
+ let url;
71
+ try {
72
+ url = new URL(value);
73
+ } catch {
74
+ throw new KleoraError("invalid_configuration", complaint);
75
+ }
76
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
77
+ throw new KleoraError("invalid_configuration", complaint);
78
+ }
79
+ return url;
80
+ }
81
+ var KEY_PREFIXES = {
82
+ production: "kl_live_",
83
+ sandbox: "kl_test_"
84
+ };
85
+ function classifyKey(apiKey) {
86
+ if (apiKey.startsWith(KEY_PREFIXES.production)) return "production";
87
+ if (apiKey.startsWith(KEY_PREFIXES.sandbox)) return "sandbox";
88
+ return null;
89
+ }
90
+ function redactKey(apiKey) {
91
+ const kind = classifyKey(apiKey);
92
+ if (kind === null) return "[redacted]";
93
+ return apiKey.slice(0, KEY_PREFIXES[kind].length + 8);
94
+ }
95
+ function assertKeyMatches(apiKey, kind, what) {
96
+ const keyKind = classifyKey(apiKey);
97
+ if (keyKind === null) {
98
+ throw new KleoraError(
99
+ "invalid_configuration",
100
+ `the API key does not start with '${KEY_PREFIXES.production}' or '${KEY_PREFIXES.sandbox}', so the environment it belongs to cannot be checked`
101
+ );
102
+ }
103
+ if (keyKind !== kind) {
104
+ throw new EnvironmentMismatch(
105
+ `the API key ${redactKey(apiKey)} is a ${keyKind} key but ${what} is ${kind}; sandbox and production must not be mixed`
106
+ );
107
+ }
108
+ }
109
+
110
+ // src/version.ts
111
+ var VERSION = "0.1.0";
112
+ var USER_AGENT = `kleora-node/${VERSION}`;
113
+
114
+ // src/jwks.ts
115
+ var DEFAULT_TTL_SECONDS = 300;
116
+ var MIN_REFETCH_SECONDS = 60;
117
+ var STALE_MULTIPLIER = 2;
118
+ var DEFAULT_TIMEOUT_MS = 5e3;
119
+ var monotonic = () => performance.now();
120
+ var JwksCache = class {
121
+ url;
122
+ doFetch;
123
+ now;
124
+ ttlOverride;
125
+ floor;
126
+ timeout;
127
+ set = null;
128
+ /** When a fetch was last *attempted*, successful or not. */
129
+ lastAttempt = null;
130
+ /** When a fetch last failed, or `null` if the last one succeeded. */
131
+ lastFailure = null;
132
+ /** Why it last failed, so `get` can report the outage rather than guess. */
133
+ lastError = null;
134
+ /** In-flight fetch, so concurrent verifications share one request. */
135
+ inFlight = null;
136
+ imported = /* @__PURE__ */ new Map();
137
+ /** Fetches performed, for the tests that count them. */
138
+ fetches = 0;
139
+ constructor(issuer, options = {}) {
140
+ this.url = new URL("/.well-known/jwks.json", issuer).href;
141
+ this.doFetch = options.fetch ?? globalThis.fetch;
142
+ this.now = options.now ?? monotonic;
143
+ this.ttlOverride = options.ttl === void 0 ? void 0 : options.ttl * 1e3;
144
+ this.floor = (options.minRefetchInterval ?? MIN_REFETCH_SECONDS) * 1e3;
145
+ this.timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
146
+ }
147
+ /**
148
+ * The key `kid` names, refetching within the rules above.
149
+ *
150
+ * Throws `TokenError('unknown_kid')` when the issuer does not publish it —
151
+ * which is also the answer when the endpoint is unreachable and the cached
152
+ * set is past 2 × TTL, because at that point the SDK cannot tell an
153
+ * unpublished key from an unfetchable one and must fail closed.
154
+ */
155
+ async get(kid) {
156
+ if (this.set === null) {
157
+ await this.refresh();
158
+ } else if (this.age() >= this.set.ttl && this.mayRetry()) {
159
+ await this.refreshQuietly();
160
+ }
161
+ if (this.lookup(kid) === void 0 && this.mayRefetch()) {
162
+ await this.refreshQuietly();
163
+ }
164
+ const jwk = this.lookup(kid);
165
+ if (jwk === void 0) {
166
+ if (!this.usable()) {
167
+ throw this.lastError ?? new TokenError("issuer_unavailable", `${this.url} could not be read`);
168
+ }
169
+ throw new TokenError(
170
+ "unknown_kid",
171
+ `the issuer at ${this.url} publishes no key with kid '${kid}'`
172
+ );
173
+ }
174
+ const cached = this.imported.get(kid);
175
+ if (cached !== void 0) return cached;
176
+ const key = await jose.importJWK(jwk, "ES256");
177
+ this.imported.set(kid, key);
178
+ return key;
179
+ }
180
+ /** Whether the cached set may still answer at all. */
181
+ usable() {
182
+ return this.set !== null && this.age() < this.set.ttl * STALE_MULTIPLIER;
183
+ }
184
+ lookup(kid) {
185
+ return this.usable() ? this.set?.keys.get(kid) : void 0;
186
+ }
187
+ age() {
188
+ return this.set === null ? Number.POSITIVE_INFINITY : this.now() - this.set.at;
189
+ }
190
+ /**
191
+ * Whether an unknown `kid` may force a fetch.
192
+ *
193
+ * The floor counts from the last *attempt*, not the last success: an issuer
194
+ * that is answering 500 would otherwise be refetched on every token, and
195
+ * the whole point of the floor is that whoever sends the token does not get
196
+ * to decide how often this process calls its issuer.
197
+ */
198
+ mayRefetch() {
199
+ return this.lastAttempt === null || this.now() - this.lastAttempt >= this.floor;
200
+ }
201
+ /**
202
+ * Whether an expired TTL may be refreshed.
203
+ *
204
+ * The document's `max-age` is the issuer's own instruction and is obeyed as
205
+ * given, floor or no floor — a deployment that publishes `max-age=30` means
206
+ * thirty seconds. What is throttled is *retrying a failure*: an endpoint
207
+ * that is down would otherwise be asked once per verification for as long
208
+ * as the stale window lasts.
209
+ */
210
+ mayRetry() {
211
+ return this.lastFailure === null || this.now() - this.lastFailure >= this.floor;
212
+ }
213
+ /** Refresh, tolerating a failure. The callers decide whether to try. */
214
+ async refreshQuietly() {
215
+ try {
216
+ await this.refresh();
217
+ } catch {
218
+ }
219
+ }
220
+ async refresh() {
221
+ this.inFlight ??= this.fetchKeys().finally(() => {
222
+ this.inFlight = null;
223
+ });
224
+ await this.inFlight;
225
+ }
226
+ async fetchKeys() {
227
+ this.lastAttempt = this.now();
228
+ this.lastFailure = this.lastAttempt;
229
+ this.fetches += 1;
230
+ try {
231
+ const response = await this.doFetch(this.url, {
232
+ headers: { Accept: "application/json", "User-Agent": USER_AGENT },
233
+ // Bounded here rather than left to the runtime: undici's default is
234
+ // 300 s, and a coalesced refresh makes that every concurrent
235
+ // verification's wait, not just the first one's.
236
+ signal: AbortSignal.timeout(this.timeout)
237
+ });
238
+ if (!response.ok) {
239
+ throw new TokenError(
240
+ "issuer_unavailable",
241
+ `${this.url} answered ${String(response.status)}`
242
+ );
243
+ }
244
+ const document = await response.json();
245
+ const keys = /* @__PURE__ */ new Map();
246
+ if (typeof document === "object" && document !== null) {
247
+ const listed = document.keys;
248
+ if (Array.isArray(listed)) {
249
+ for (const entry of listed) {
250
+ if (typeof entry.kid === "string") keys.set(entry.kid, entry);
251
+ }
252
+ }
253
+ }
254
+ if (keys.size === 0) {
255
+ throw new TokenError("issuer_unavailable", `${this.url} published no usable key`);
256
+ }
257
+ this.set = { keys, at: this.now(), ttl: this.ttlOverride ?? maxAge(response) };
258
+ this.lastFailure = null;
259
+ this.lastError = null;
260
+ for (const kid of [...this.imported.keys()]) {
261
+ if (!keys.has(kid)) this.imported.delete(kid);
262
+ }
263
+ } catch (cause) {
264
+ const error = cause instanceof TokenError ? cause : new TokenError("issuer_unavailable", `${this.url} could not be read: ${reason(cause)}`, {
265
+ cause
266
+ });
267
+ this.lastError = error;
268
+ throw error;
269
+ }
270
+ }
271
+ };
272
+ function maxAge(response) {
273
+ const header = response.headers.get("cache-control");
274
+ const match = header === null ? null : /(?:^|[\s,])max-age\s*=\s*(\d+)/i.exec(header);
275
+ const seconds = match?.[1] === void 0 ? Number.NaN : Number(match[1]);
276
+ return (Number.isFinite(seconds) && seconds > 0 ? seconds : DEFAULT_TTL_SECONDS) * 1e3;
277
+ }
278
+ function reason(cause) {
279
+ if (cause instanceof Error) {
280
+ return cause.name === "TimeoutError" ? "the request timed out" : cause.message;
281
+ }
282
+ return "unknown error";
283
+ }
284
+ var caches = /* @__PURE__ */ new Map();
285
+ function jwksFor(issuer, options = {}) {
286
+ const url = new URL("/.well-known/jwks.json", issuer).href;
287
+ let cache = caches.get(url);
288
+ if (cache === void 0) {
289
+ cache = new JwksCache(issuer, options);
290
+ caches.set(url, cache);
291
+ }
292
+ return cache;
293
+ }
294
+ function clearJwksCaches() {
295
+ caches.clear();
296
+ }
297
+ var CLOCK_TOLERANCE_SECONDS = 60;
298
+ var ALGORITHM = "ES256";
299
+ var ACCESS_TOKEN_TYPES = /* @__PURE__ */ new Set(["at+jwt", "application/at+jwt"]);
300
+ var BEARER = /^Bearer +(?<token>[^\s]+)$/i;
301
+ async function verifyAccessToken(token, options) {
302
+ const issuer = parseAbsolute("issuer", options.issuer);
303
+ const expectedIssuer = trimTrailingSlash(options.issuer);
304
+ const tolerance = clockTolerance(options);
305
+ const keys = options.jwks ?? resolverFor(options);
306
+ const header = readHeader(token);
307
+ if (header.alg !== ALGORITHM) {
308
+ throw new TokenError(
309
+ "malformed",
310
+ `the token is signed with '${String(header.alg)}'; this issuer signs ${ALGORITHM} only`
311
+ );
312
+ }
313
+ const typ = typeof header.typ === "string" ? header.typ.toLowerCase() : null;
314
+ if (typ === null || !ACCESS_TOKEN_TYPES.has(typ)) {
315
+ throw new TokenError(
316
+ "invalid_type",
317
+ `the token header's typ is ${typ === null ? "absent or not a string" : `'${typ}'`}; an access token is 'at+jwt' (RFC 9068 \xA72.1)`
318
+ );
319
+ }
320
+ if (typeof header.kid !== "string" || header.kid === "") {
321
+ throw new TokenError("malformed", "the token header names no kid");
322
+ }
323
+ const key = await keys.get(header.kid);
324
+ let payload;
325
+ try {
326
+ const verified = await jose.compactVerify(token, key, { algorithms: [ALGORITHM] });
327
+ payload = new TextDecoder().decode(verified.payload);
328
+ } catch (cause) {
329
+ throw new TokenError("invalid_signature", `the token's signature does not verify`, {
330
+ cause
331
+ });
332
+ }
333
+ const claims = parseClaims(payload);
334
+ if (claims.iss !== expectedIssuer) {
335
+ throw new TokenError(
336
+ "issuer_mismatch",
337
+ `the token's iss is '${claims.iss}', expected '${expectedIssuer}'`
338
+ );
339
+ }
340
+ if (!claims.aud.includes(options.audience)) {
341
+ throw new TokenError(
342
+ "audience_mismatch",
343
+ `the token's aud ${JSON.stringify(claims.aud)} does not contain '${options.audience}'`
344
+ );
345
+ }
346
+ const now = Math.floor((options.now?.() ?? Date.now()) / 1e3);
347
+ if (now > claims.exp + tolerance) {
348
+ throw new TokenError(
349
+ "expired",
350
+ `the token expired at ${String(claims.exp)}, now ${String(now)} (clock tolerance ${String(tolerance)}s)`
351
+ );
352
+ }
353
+ if (claims.iat > now + tolerance) {
354
+ throw new TokenError(
355
+ "expired",
356
+ `the token was issued at ${String(claims.iat)}, in the future of ${String(now)} (clock tolerance ${String(tolerance)}s)`
357
+ );
358
+ }
359
+ const kind = classify(issuer.host);
360
+ if (claims.env !== kind) {
361
+ throw new EnvironmentMismatch(
362
+ `the token's env claim is '${claims.env}' but the issuer '${options.issuer}' is ${kind}`
363
+ );
364
+ }
365
+ return claims;
366
+ }
367
+ async function authenticate(request, options) {
368
+ return verifyAccessToken(bearerOf(request), options);
369
+ }
370
+ function bearerOf(request) {
371
+ const header = request.headers.authorization;
372
+ if (typeof header !== "string" || header.trim() === "") {
373
+ throw new LoginRequired("the request carries no Authorization header");
374
+ }
375
+ const token = BEARER.exec(header.trim())?.groups?.["token"];
376
+ if (token === void 0) {
377
+ throw new LoginRequired("the Authorization header is not a Bearer credential");
378
+ }
379
+ return token;
380
+ }
381
+ function resolverFor(options) {
382
+ const cacheOptions = {};
383
+ if (options.fetch !== void 0) cacheOptions.fetch = options.fetch;
384
+ return jwksFor(options.issuer, cacheOptions);
385
+ }
386
+ function trimTrailingSlash(value) {
387
+ return value.replace(/\/+$/, "");
388
+ }
389
+ function clockTolerance(options) {
390
+ const value = options.clockTolerance ?? CLOCK_TOLERANCE_SECONDS;
391
+ if (!Number.isFinite(value) || value < 0) {
392
+ throw new KleoraError(
393
+ "invalid_configuration",
394
+ `clockTolerance must be a finite number of seconds >= 0, got ${String(value)}`
395
+ );
396
+ }
397
+ return value;
398
+ }
399
+ function assertVerifyOptions(options) {
400
+ parseAbsolute("issuer", options.issuer);
401
+ if (typeof options.audience !== "string" || options.audience === "") {
402
+ throw new KleoraError(
403
+ "invalid_configuration",
404
+ "audience must be a non-empty string: the environment's api_audience, or the client id when it declares none"
405
+ );
406
+ }
407
+ clockTolerance(options);
408
+ }
409
+ function readHeader(token) {
410
+ try {
411
+ return jose.decodeProtectedHeader(token);
412
+ } catch (cause) {
413
+ throw new TokenError("malformed", "the token is not a compact JWS", { cause });
414
+ }
415
+ }
416
+ function parseClaims(payload) {
417
+ let document;
418
+ try {
419
+ document = JSON.parse(payload);
420
+ } catch (cause) {
421
+ throw new TokenError("malformed", "the token payload is not JSON", { cause });
422
+ }
423
+ if (typeof document !== "object" || document === null || Array.isArray(document)) {
424
+ throw new TokenError("malformed", "the token payload is not a JSON object");
425
+ }
426
+ const claims = document;
427
+ const audience = claims["aud"];
428
+ const aud = typeof audience === "string" ? [audience] : Array.isArray(audience) && audience.every((entry) => typeof entry === "string") ? audience : null;
429
+ const required = {
430
+ iss: string(claims["iss"]),
431
+ sub: string(claims["sub"]),
432
+ jti: string(claims["jti"]),
433
+ client_id: string(claims["client_id"]),
434
+ scope: string(claims["scope"]),
435
+ exp: integer(claims["exp"]),
436
+ iat: integer(claims["iat"])
437
+ };
438
+ const env = claims["env"];
439
+ const missing = Object.entries(required).filter(([, value]) => value === null).map(([name]) => name);
440
+ if (aud === null) missing.push("aud");
441
+ if (env !== "sandbox" && env !== "production") missing.push("env");
442
+ if (missing.length > 0) {
443
+ throw new TokenError(
444
+ "malformed",
445
+ `the token is missing or misdeclares: ${missing.sort().join(", ")}`
446
+ );
447
+ }
448
+ return {
449
+ ...claims,
450
+ iss: required.iss,
451
+ sub: required.sub,
452
+ aud,
453
+ exp: required.exp,
454
+ iat: required.iat,
455
+ jti: required.jti,
456
+ client_id: required.client_id,
457
+ scope: required.scope,
458
+ env,
459
+ roles: stringList(claims["roles"]),
460
+ permissions: stringList(claims["permissions"]),
461
+ amr: stringList(claims["amr"])
462
+ };
463
+ }
464
+ var string = (value) => typeof value === "string" ? value : null;
465
+ var integer = (value) => typeof value === "number" && Number.isFinite(value) ? value : null;
466
+ var stringList = (value) => Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
467
+
468
+ // src/problem.ts
469
+ var PROBLEM_BASE = "https://kleora.io/problems/";
470
+ var TITLES = {
471
+ unauthenticated: "Authentication required",
472
+ "insufficient-permission": "Permission denied",
473
+ "issuer-unavailable": "Identity provider unavailable"
474
+ };
475
+ function problem(slug, status, detail) {
476
+ return {
477
+ type: `${PROBLEM_BASE}${slug}`,
478
+ title: TITLES[slug] ?? slug,
479
+ status,
480
+ detail
481
+ };
482
+ }
483
+ var isFastify = (response) => typeof response.code === "function";
484
+ function sendProblem(response, document, headers = {}) {
485
+ const all = {
486
+ "Content-Type": "application/problem+json",
487
+ ...document.status === 401 ? { "WWW-Authenticate": "Bearer" } : {},
488
+ ...headers
489
+ };
490
+ if (isFastify(response)) {
491
+ response.code(document.status);
492
+ for (const [name, value] of Object.entries(all)) {
493
+ if (name === "Content-Type") response.type(value);
494
+ else response.header(name, value);
495
+ }
496
+ response.send(document);
497
+ return;
498
+ }
499
+ response.status(document.status);
500
+ for (const [name, value] of Object.entries(all)) response.setHeader(name, value);
501
+ response.json(document);
502
+ }
503
+
504
+ // src/middleware.ts
505
+ var UNAUTHENTICATED = "the request could not be authenticated";
506
+ function refusal(error) {
507
+ if (error instanceof TokenError && error.code === "issuer_unavailable") {
508
+ return problem(
509
+ "issuer-unavailable",
510
+ 503,
511
+ "the identity provider could not be reached to verify this token"
512
+ );
513
+ }
514
+ if (error instanceof TokenError || error instanceof LoginRequired) {
515
+ return problem("unauthenticated", 401, UNAUTHENTICATED);
516
+ }
517
+ return null;
518
+ }
519
+ function report(options, error, mustLog) {
520
+ if (options.logger !== void 0) {
521
+ try {
522
+ options.logger(error);
523
+ } catch {
524
+ }
525
+ return;
526
+ }
527
+ if (mustLog) console.error("[@kleora-io/node]", error);
528
+ }
529
+ var asError = (error) => error instanceof Error ? error : new Error(String(error));
530
+ function express(options) {
531
+ assertVerifyOptions(options);
532
+ return (request, response, next) => {
533
+ void admit(request, response, options).then(
534
+ (verdict) => {
535
+ if (verdict.kind === "ok") next();
536
+ else if (verdict.kind === "internal") next(asError(verdict.error));
537
+ },
538
+ (error) => {
539
+ report(options, error, true);
540
+ next(asError(error));
541
+ }
542
+ ).catch((error) => {
543
+ report(options, error, true);
544
+ });
545
+ };
546
+ }
547
+ function fastify(options) {
548
+ assertVerifyOptions(options);
549
+ const plugin = (instance) => {
550
+ instance.decorateRequest("kleora", null);
551
+ instance.addHook("onRequest", async (request, reply) => {
552
+ const verdict = await admit(request, reply, options);
553
+ if (verdict.kind === "internal") {
554
+ report(options, verdict.error, true);
555
+ throw asError(verdict.error);
556
+ }
557
+ return verdict.kind === "ok" ? void 0 : reply;
558
+ });
559
+ return Promise.resolve();
560
+ };
561
+ return Object.assign(plugin, { [/* @__PURE__ */ Symbol.for("skip-override")]: true });
562
+ }
563
+ async function admit(request, response, options) {
564
+ let claims;
565
+ try {
566
+ claims = await authenticate(request, options);
567
+ } catch (error) {
568
+ const document = refusal(error);
569
+ if (document === null) return { kind: "internal", error };
570
+ report(options, error, false);
571
+ try {
572
+ sendProblem(response, document);
573
+ } catch (failure) {
574
+ report(options, failure, true);
575
+ }
576
+ return { kind: "answered" };
577
+ }
578
+ request.kleora = claims;
579
+ return { kind: "ok" };
580
+ }
581
+ function requirePermission(name) {
582
+ return (request, response, next) => {
583
+ const claims = request.kleora;
584
+ if (claims === void 0 || claims === null) {
585
+ sendProblem(response, problem("unauthenticated", 401, UNAUTHENTICATED));
586
+ return;
587
+ }
588
+ if (!claims.permissions.includes(name)) {
589
+ sendProblem(
590
+ response,
591
+ problem(
592
+ "insufficient-permission",
593
+ 403,
594
+ claims.permissions_truncated === true ? `the token does not carry the permission '${name}'. Its permissions claim was truncated at 200 entries, so this may be a permission the user does hold but the token could not carry; coarser roles are the fix.` : `the token does not carry the permission '${name}'`
595
+ )
596
+ );
597
+ return;
598
+ }
599
+ next?.();
600
+ };
601
+ }
602
+
603
+ // src/management.ts
604
+ var DEFAULT_BASE_URL = "https://api.kleora.io";
605
+ var PREFIX = "/api/v1";
606
+ var DEFAULT_TIMEOUT_MS2 = 1e4;
607
+ var ManagementClient = class {
608
+ baseUrl;
609
+ issuer;
610
+ #apiKey;
611
+ #fetch;
612
+ #timeout;
613
+ /** The guard, run at most once; a rejection is remembered and re-thrown. */
614
+ #guard = null;
615
+ #environment = null;
616
+ constructor(options) {
617
+ if (classifyKey(options.apiKey) === null) {
618
+ throw new KleoraError(
619
+ "invalid_configuration",
620
+ `apiKey must start with 'kl_live_' or 'kl_test_'`
621
+ );
622
+ }
623
+ this.#apiKey = options.apiKey;
624
+ this.#fetch = options.fetch ?? globalThis.fetch;
625
+ this.#timeout = options.timeout ?? DEFAULT_TIMEOUT_MS2;
626
+ this.baseUrl = normaliseBase(parseAbsolute("baseUrl", options.baseUrl ?? DEFAULT_BASE_URL).href);
627
+ this.issuer = options.issuer === void 0 ? null : trimSlash(options.issuer);
628
+ if (options.issuer !== void 0) {
629
+ const parsed = parseAbsolute("issuer", options.issuer);
630
+ assertKeyMatches(
631
+ options.apiKey,
632
+ classify(parsed.host),
633
+ `the configured issuer '${this.issuer}'`
634
+ );
635
+ }
636
+ }
637
+ /** The key's environment, once the guard has read it. `null` before that. */
638
+ get environment() {
639
+ return this.#environment;
640
+ }
641
+ get(path, ...args) {
642
+ return this.#call("GET", path, args[0]);
643
+ }
644
+ post(path, ...args) {
645
+ return this.#call("POST", path, args[0]);
646
+ }
647
+ put(path, ...args) {
648
+ return this.#call("PUT", path, args[0]);
649
+ }
650
+ patch(path, ...args) {
651
+ return this.#call("PATCH", path, args[0]);
652
+ }
653
+ delete(path, ...args) {
654
+ return this.#call("DELETE", path, args[0]);
655
+ }
656
+ /** The prefix, and nothing else. The key itself never leaves this object. */
657
+ toString() {
658
+ return `ManagementClient(${redactKey(this.#apiKey)} @ ${this.baseUrl})`;
659
+ }
660
+ toJSON() {
661
+ return { apiKey: redactKey(this.#apiKey), baseUrl: this.baseUrl, issuer: this.issuer };
662
+ }
663
+ /** `console.log(client)` and `util.inspect` see the same redaction. */
664
+ [/* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom")]() {
665
+ return this.toString();
666
+ }
667
+ async #call(method, path, args) {
668
+ await this.#ensureGuarded(args);
669
+ return this.#send(method, path, args);
670
+ }
671
+ /**
672
+ * The environment guard.
673
+ *
674
+ * The remote half. The local half — key prefix against the configured issuer
675
+ * — already ran in the constructor, which is what "no request with the key is
676
+ * sent" means for a sandbox key aimed at production. This is
677
+ * one `GET /me`, and it is the authority: the prefix says which *kind* of
678
+ * environment the key belongs to, and only the server says which environment
679
+ * that is.
680
+ */
681
+ #ensureGuarded(args) {
682
+ this.#guard ??= this.#runGuard(args).catch((error) => {
683
+ if (!(error instanceof EnvironmentMismatch)) this.#guard = null;
684
+ throw error;
685
+ });
686
+ return this.#guard;
687
+ }
688
+ async #runGuard(args) {
689
+ const me = await this.#send(
690
+ "GET",
691
+ `${PREFIX}/me`,
692
+ args?.requestId === void 0 ? {} : { requestId: args.requestId }
693
+ );
694
+ const environment = me.environment ?? null;
695
+ if (environment === null) {
696
+ throw new EnvironmentMismatch(
697
+ `GET ${PREFIX}/me reported no environment for ${redactKey(this.#apiKey)}`
698
+ );
699
+ }
700
+ assertKeyMatches(this.#apiKey, environment.kind, `its environment '${environment.issuer}'`);
701
+ if (this.issuer !== null && trimSlash(environment.issuer) !== this.issuer) {
702
+ throw new EnvironmentMismatch(
703
+ `the key belongs to '${environment.issuer}' but the client was configured with issuer '${this.issuer}'`
704
+ );
705
+ }
706
+ this.#environment = environment;
707
+ }
708
+ async #send(method, path, args) {
709
+ const url = new URL(this.baseUrl + expand(path, args?.path));
710
+ for (const [name, value] of queryEntries(args?.query)) url.searchParams.append(name, value);
711
+ const headers = {
712
+ Accept: "application/json, application/problem+json",
713
+ Authorization: `Bearer ${this.#apiKey}`,
714
+ "User-Agent": USER_AGENT
715
+ };
716
+ if (args?.requestId !== void 0) headers["X-Request-Id"] = args.requestId;
717
+ if (args?.idempotencyKey !== void 0) headers["Idempotency-Key"] = args.idempotencyKey;
718
+ if (args?.body !== void 0) headers["Content-Type"] = "application/json";
719
+ const response = await this.#fetch(url, {
720
+ method,
721
+ headers,
722
+ ...args?.body === void 0 ? {} : { body: JSON.stringify(args.body) },
723
+ signal: deadline(this.#timeout, args?.signal)
724
+ });
725
+ if (!response.ok) throw await toApiError(response);
726
+ if (response.status === 204) return void 0;
727
+ const text = await response.text();
728
+ return text === "" ? void 0 : JSON.parse(text);
729
+ }
730
+ };
731
+ function normaliseBase(value) {
732
+ const trimmed = trimSlash(value);
733
+ return trimmed.endsWith(PREFIX) ? trimmed.slice(0, -PREFIX.length) : trimmed;
734
+ }
735
+ var trimSlash = (value) => value.replace(/\/+$/, "");
736
+ function deadline(timeout, signal) {
737
+ const own = AbortSignal.timeout(timeout);
738
+ return signal === void 0 ? own : AbortSignal.any([signal, own]);
739
+ }
740
+ function expand(path, params) {
741
+ return path.replace(/\{([^}]+)\}/g, (_, name) => {
742
+ const value = params?.[name];
743
+ if (value === void 0 || value === null) {
744
+ throw new KleoraError(
745
+ "invalid_request",
746
+ `${path} needs the path parameter '${name}'`
747
+ );
748
+ }
749
+ return encodeURIComponent(String(value));
750
+ });
751
+ }
752
+ function queryEntries(query) {
753
+ const entries = [];
754
+ for (const [name, value] of Object.entries(query ?? {})) {
755
+ if (value === void 0 || value === null) continue;
756
+ for (const item of Array.isArray(value) ? value : [value]) {
757
+ if (typeof item === "string") entries.push([name, item]);
758
+ else if (typeof item === "number" || typeof item === "boolean") {
759
+ entries.push([name, String(item)]);
760
+ }
761
+ }
762
+ }
763
+ return entries;
764
+ }
765
+ async function toApiError(response) {
766
+ let document = {};
767
+ try {
768
+ const body = await response.json();
769
+ if (typeof body === "object" && body !== null && !Array.isArray(body)) {
770
+ document = body;
771
+ }
772
+ } catch {
773
+ }
774
+ const errors = document["errors"];
775
+ const problem2 = {
776
+ document,
777
+ retryAfter: retryAfterOf(response, document),
778
+ idempotencyReplayed: response.headers.get("idempotency-replayed")
779
+ };
780
+ return new ApiError(
781
+ typeof document["type"] === "string" ? document["type"] : "about:blank",
782
+ response.status,
783
+ typeof document["detail"] === "string" ? document["detail"] : response.statusText === "" ? void 0 : response.statusText,
784
+ Array.isArray(errors) ? errors : void 0,
785
+ problem2,
786
+ requestIdOf(response, document)
787
+ );
788
+ }
789
+ function requestIdOf(response, document) {
790
+ const fromDocument = document["request_id"];
791
+ if (typeof fromDocument === "string" && fromDocument !== "") return fromDocument;
792
+ return response.headers.get("x-request-id") ?? void 0;
793
+ }
794
+ function retryAfterOf(response, document) {
795
+ const raw = response.headers.get("retry-after");
796
+ const trimmed = raw?.trim() ?? "";
797
+ if (trimmed !== "") {
798
+ const seconds = Number(trimmed);
799
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds);
800
+ const at = Date.parse(trimmed);
801
+ if (Number.isFinite(at)) return Math.max(0, Math.round((at - Date.now()) / 1e3));
802
+ }
803
+ const fromDocument = document["retry_after"];
804
+ return typeof fromDocument === "number" && Number.isFinite(fromDocument) ? fromDocument : null;
805
+ }
806
+
807
+ // src/account.ts
808
+ var DEFAULT_ACCOUNT_TIMEOUT_MS = 1e4;
809
+ var id = (value) => encodeURIComponent(value);
810
+ var AccountClient = class {
811
+ /** `{issuer}/account/v1`. */
812
+ baseUrl;
813
+ #accessToken;
814
+ #fetch;
815
+ #timeout;
816
+ constructor(options) {
817
+ const issuer = parseAbsolute("issuer", options.issuer);
818
+ if (options.accessToken === "") {
819
+ throw new KleoraError("invalid_configuration", "accessToken must not be empty");
820
+ }
821
+ this.baseUrl = `${issuer.href.replace(/\/+$/, "")}/account/v1`;
822
+ this.#accessToken = options.accessToken;
823
+ this.#fetch = options.fetch ?? globalThis.fetch;
824
+ this.#timeout = options.timeout ?? DEFAULT_ACCOUNT_TIMEOUT_MS;
825
+ }
826
+ /** `GET /profile`. */
827
+ getProfile() {
828
+ return this.#send("GET", "/profile");
829
+ }
830
+ /** `PATCH /profile` — name, locale, picture; `null` clears one. */
831
+ updateProfile(changes) {
832
+ return this.#send("PATCH", "/profile", { body: changes });
833
+ }
834
+ /** `GET /email`. */
835
+ getEmail() {
836
+ return this.#send("GET", "/email");
837
+ }
838
+ /** `POST /email` — mails a confirmation link to the new address. **Sensitive.** */
839
+ requestEmailChange(email) {
840
+ return this.#send("POST", "/email", { body: { email } });
841
+ }
842
+ /** `POST /email/confirm` — the token from that link. */
843
+ confirmEmailChange(token) {
844
+ return this.#send("POST", "/email/confirm", { body: { token } });
845
+ }
846
+ /** `POST /email/resend`. */
847
+ resendVerificationEmail() {
848
+ return this.#send("POST", "/email/resend");
849
+ }
850
+ /** `GET /password` — whether there is one, and the Environment's policy. */
851
+ getPasswordPolicy() {
852
+ return this.#send("GET", "/password");
853
+ }
854
+ /** `POST /password` — change it, or set one for a person who has none. **Sensitive.** */
855
+ changePassword(options) {
856
+ return this.#send("POST", "/password", {
857
+ body: {
858
+ new_password: options.newPassword,
859
+ ...options.currentPassword === void 0 ? {} : { current_password: options.currentPassword }
860
+ }
861
+ });
862
+ }
863
+ /** `GET /reauth` — what this person can re-authenticate with. */
864
+ getReauthentication() {
865
+ return this.#send("GET", "/reauth");
866
+ }
867
+ /** `POST /reauth` — a password; opens the five-minute window. */
868
+ reauthenticate(password) {
869
+ return this.#send("POST", "/reauth", { body: { password } });
870
+ }
871
+ /** `GET /sessions` — live sessions, newest first, one cursor page. */
872
+ listSessions(options = {}) {
873
+ return this.#send("GET", "/sessions", { query: { limit: options.limit, cursor: options.cursor } });
874
+ }
875
+ /** `DELETE /sessions/{id}`. */
876
+ revokeSession(sessionId) {
877
+ return this.#send("DELETE", `/sessions/${id(sessionId)}`);
878
+ }
879
+ /** `POST /sessions/revoke-others` — every live session but this token's. */
880
+ revokeOtherSessions() {
881
+ return this.#send("POST", "/sessions/revoke-others");
882
+ }
883
+ /** `GET /mfa`. */
884
+ listMfaFactors() {
885
+ return this.#send("GET", "/mfa");
886
+ }
887
+ /** `POST /mfa/enroll/start`. **Sensitive.** */
888
+ startTotpEnrollment() {
889
+ return this.#send("POST", "/mfa/enroll/start");
890
+ }
891
+ /** `POST /mfa/enroll/confirm` — answers the recovery codes, once. **Sensitive.** */
892
+ confirmTotpEnrollment(code) {
893
+ return this.#send("POST", "/mfa/enroll/confirm", { body: { code } });
894
+ }
895
+ /** `DELETE /mfa/{id}`. **Sensitive.** */
896
+ removeMfaFactor(factorId) {
897
+ return this.#send("DELETE", `/mfa/${id(factorId)}`);
898
+ }
899
+ /** `POST /mfa/recovery/regenerate`. **Sensitive.** */
900
+ regenerateRecoveryCodes() {
901
+ return this.#send("POST", "/mfa/recovery/regenerate");
902
+ }
903
+ /** `GET /passkeys`. */
904
+ listPasskeys() {
905
+ return this.#send("GET", "/passkeys");
906
+ }
907
+ /** `DELETE /passkeys/{id}`. **Sensitive.** */
908
+ removePasskey(passkeyId) {
909
+ return this.#send("DELETE", `/passkeys/${id(passkeyId)}`);
910
+ }
911
+ /** `GET /identities`. */
912
+ listIdentities() {
913
+ return this.#send("GET", "/identities");
914
+ }
915
+ /** `DELETE /identities/{id}`. **Sensitive.** */
916
+ unlinkIdentity(identityId) {
917
+ return this.#send("DELETE", `/identities/${id(identityId)}`);
918
+ }
919
+ /** `GET /settings`. */
920
+ listSettings() {
921
+ return this.#send("GET", "/settings");
922
+ }
923
+ /** `PUT /settings/{key}`. */
924
+ setSetting(key, value) {
925
+ return this.#send("PUT", `/settings/${id(key)}`, { body: { value } });
926
+ }
927
+ /** `DELETE /settings/{key}` — back to the definition's default. */
928
+ resetSetting(key) {
929
+ return this.#send("DELETE", `/settings/${id(key)}`);
930
+ }
931
+ /** `GET /portal` — the hosted portal's entry URL and the sections offered. */
932
+ getPortal() {
933
+ return this.#send("GET", "/portal");
934
+ }
935
+ /** `POST /deletion` — deleted permanently after 7 days, cancellable until then. **Sensitive.** */
936
+ requestDeletion() {
937
+ return this.#send("POST", "/deletion");
938
+ }
939
+ /** `DELETE /deletion` — the emailed cancellation token; the one anonymous operation. */
940
+ cancelDeletion(token) {
941
+ return this.#send("DELETE", "/deletion", { body: { token }, anonymous: true });
942
+ }
943
+ toString() {
944
+ return `AccountClient(${this.baseUrl})`;
945
+ }
946
+ toJSON() {
947
+ return { baseUrl: this.baseUrl };
948
+ }
949
+ [/* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom")]() {
950
+ return this.toString();
951
+ }
952
+ async #token() {
953
+ const token = typeof this.#accessToken === "function" ? await this.#accessToken() : this.#accessToken;
954
+ if (token === "") throw new KleoraError("invalid_configuration", "accessToken returned an empty token");
955
+ return token;
956
+ }
957
+ async #send(method, path, call = {}) {
958
+ const url = new URL(this.baseUrl + path);
959
+ for (const [name, value] of Object.entries(call.query ?? {})) {
960
+ if (value !== void 0) url.searchParams.set(name, String(value));
961
+ }
962
+ const headers = {
963
+ Accept: "application/json, application/problem+json",
964
+ "User-Agent": USER_AGENT
965
+ };
966
+ if (call.anonymous !== true) headers["Authorization"] = `Bearer ${await this.#token()}`;
967
+ if (call.body !== void 0) headers["Content-Type"] = "application/json";
968
+ const response = await this.#fetch(url, {
969
+ method,
970
+ headers,
971
+ ...call.body === void 0 ? {} : { body: JSON.stringify(call.body) },
972
+ signal: AbortSignal.timeout(this.#timeout)
973
+ });
974
+ if (!response.ok) throw await toApiError(response);
975
+ if (response.status === 204) return void 0;
976
+ const text = await response.text();
977
+ return text === "" ? void 0 : JSON.parse(text);
978
+ }
979
+ };
980
+
981
+ // src/portal.ts
982
+ var ACCOUNT_PORTAL_SECTIONS = [
983
+ "profile",
984
+ "password",
985
+ "security",
986
+ "sessions",
987
+ "danger"
988
+ ];
989
+ var invalid = (message) => new KleoraError("invalid_configuration", message);
990
+ function assertReturnTo(value) {
991
+ const url = parseAbsolute("returnTo", value);
992
+ if (url.hash !== "" || value.includes("#")) {
993
+ throw invalid(`returnTo must not carry a fragment, got '${value}'`);
994
+ }
995
+ if (url.username !== "" || url.password !== "") {
996
+ throw invalid(`returnTo must not carry userinfo, got '${value}'`);
997
+ }
998
+ }
999
+ function assertSection(section) {
1000
+ if (!ACCOUNT_PORTAL_SECTIONS.includes(section)) {
1001
+ throw invalid(
1002
+ `section must be one of ${ACCOUNT_PORTAL_SECTIONS.join(", ")}; got '${section}'`
1003
+ );
1004
+ }
1005
+ }
1006
+ function accountPortalUrl(options) {
1007
+ const issuer = parseAbsolute("issuer", options.issuer);
1008
+ if (options.clientId.length === 0) {
1009
+ throw invalid("clientId must not be empty");
1010
+ }
1011
+ if (options.section !== void 0) assertSection(options.section);
1012
+ if (options.returnTo !== void 0) assertReturnTo(options.returnTo);
1013
+ const base = issuer.href.replace(/\/+$/, "");
1014
+ const path = options.section === void 0 ? "/account" : `/account/${options.section}`;
1015
+ const url = new URL(base + path);
1016
+ url.searchParams.set("client_id", options.clientId);
1017
+ if (options.returnTo !== void 0) url.searchParams.set("return_to", options.returnTo);
1018
+ return url.href;
1019
+ }
1020
+
1021
+ exports.ACCOUNT_PORTAL_SECTIONS = ACCOUNT_PORTAL_SECTIONS;
1022
+ exports.AccountClient = AccountClient;
1023
+ exports.ApiError = ApiError;
1024
+ exports.CLOCK_TOLERANCE_SECONDS = CLOCK_TOLERANCE_SECONDS;
1025
+ exports.DEFAULT_ACCOUNT_TIMEOUT_MS = DEFAULT_ACCOUNT_TIMEOUT_MS;
1026
+ exports.DEFAULT_BASE_URL = DEFAULT_BASE_URL;
1027
+ exports.DEFAULT_JWKS_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
1028
+ exports.DEFAULT_MANAGEMENT_TIMEOUT_MS = DEFAULT_TIMEOUT_MS2;
1029
+ exports.DEFAULT_TTL_SECONDS = DEFAULT_TTL_SECONDS;
1030
+ exports.EnvironmentMismatch = EnvironmentMismatch;
1031
+ exports.JwksCache = JwksCache;
1032
+ exports.KEY_PREFIXES = KEY_PREFIXES;
1033
+ exports.KleoraError = KleoraError;
1034
+ exports.LoginRequired = LoginRequired;
1035
+ exports.MIN_REFETCH_SECONDS = MIN_REFETCH_SECONDS;
1036
+ exports.ManagementClient = ManagementClient;
1037
+ exports.PROBLEM_BASE = PROBLEM_BASE;
1038
+ exports.STALE_MULTIPLIER = STALE_MULTIPLIER;
1039
+ exports.TokenError = TokenError;
1040
+ exports.USER_AGENT = USER_AGENT;
1041
+ exports.VERSION = VERSION;
1042
+ exports.accountPortalUrl = accountPortalUrl;
1043
+ exports.assertVerifyOptions = assertVerifyOptions;
1044
+ exports.authenticate = authenticate;
1045
+ exports.classify = classify;
1046
+ exports.classifyKey = classifyKey;
1047
+ exports.clearJwksCaches = clearJwksCaches;
1048
+ exports.express = express;
1049
+ exports.fastify = fastify;
1050
+ exports.jwksFor = jwksFor;
1051
+ exports.problem = problem;
1052
+ exports.redactKey = redactKey;
1053
+ exports.requirePermission = requirePermission;
1054
+ exports.sendProblem = sendProblem;
1055
+ exports.verifyAccessToken = verifyAccessToken;
1056
+ //# sourceMappingURL=index.cjs.map
1057
+ //# sourceMappingURL=index.cjs.map