@canopy-io/node 0.1.0 → 0.2.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/README.md +102 -36
- package/dist/index.cjs +767 -48
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1106 -184
- package/dist/index.d.ts +1106 -184
- package/dist/index.js +761 -49
- package/dist/index.js.map +1 -1
- package/package.json +15 -30
package/dist/index.js
CHANGED
|
@@ -7,13 +7,20 @@ var CanopyError = class extends Error {
|
|
|
7
7
|
data;
|
|
8
8
|
/** The path and method that failed, for logging. */
|
|
9
9
|
request;
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* How long to wait before retrying, in milliseconds, when the server said so
|
|
12
|
+
* via `Retry-After` — populated on a 429, and on any other response that
|
|
13
|
+
* carries the header. Undefined when the server gave no guidance.
|
|
14
|
+
*/
|
|
15
|
+
retryAfterMs;
|
|
16
|
+
constructor(body, request, retryAfterMs) {
|
|
11
17
|
super(body.message);
|
|
12
18
|
this.statusCode = body.statusCode;
|
|
13
19
|
this.code = body.code;
|
|
14
20
|
this.details = body.details;
|
|
15
21
|
this.data = body.data;
|
|
16
22
|
this.request = request;
|
|
23
|
+
this.retryAfterMs = retryAfterMs;
|
|
17
24
|
}
|
|
18
25
|
/** A 429. `retryAfterMs` is populated when the server said how long to wait. */
|
|
19
26
|
get isRateLimited() {
|
|
@@ -32,12 +39,48 @@ var CanopyConnectionError = class extends Error {
|
|
|
32
39
|
this.request = request;
|
|
33
40
|
}
|
|
34
41
|
};
|
|
42
|
+
var CanopyTokenError = class extends Error {
|
|
43
|
+
name = "CanopyTokenError";
|
|
44
|
+
/**
|
|
45
|
+
* One of:
|
|
46
|
+
*
|
|
47
|
+
* - `token.malformed` — not a JWS, or a segment would not decode
|
|
48
|
+
* - `token.unsupported_algorithm` — not RS256; Canopy issues only RS256
|
|
49
|
+
* - `token.key_not_found` — no published key matches the token's `kid`
|
|
50
|
+
* - `token.jwks_unavailable` — the key set could not be fetched or parsed
|
|
51
|
+
* - `token.signature_invalid` — signature does not match the signing key
|
|
52
|
+
* - `token.expired` / `token.not_yet_valid` — outside its validity window
|
|
53
|
+
* - `token.issuer_mismatch` — `iss` is not the configured issuer
|
|
54
|
+
* - `token.audience_mismatch` — `aud` does not include the configured audience
|
|
55
|
+
* - `token.audience_unverified` — token has an `aud` but none was configured
|
|
56
|
+
* - `token.preauth_not_allowed` — a pre-auth token, which grants no access
|
|
57
|
+
*/
|
|
58
|
+
code;
|
|
59
|
+
constructor(code, message, options) {
|
|
60
|
+
super(message, options);
|
|
61
|
+
this.code = code;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
var CanopyAuthorizerError = class extends Error {
|
|
65
|
+
name = "CanopyAuthorizerError";
|
|
66
|
+
code;
|
|
67
|
+
constructor(code, message, options) {
|
|
68
|
+
super(message, options);
|
|
69
|
+
this.code = code;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
function isCanopyAuthorizerError(error) {
|
|
73
|
+
return error instanceof Error && error.name === "CanopyAuthorizerError";
|
|
74
|
+
}
|
|
35
75
|
function isCanopyError(error) {
|
|
36
76
|
return error instanceof Error && error.name === "CanopyError";
|
|
37
77
|
}
|
|
38
78
|
function isCanopyConnectionError(error) {
|
|
39
79
|
return error instanceof Error && error.name === "CanopyConnectionError";
|
|
40
80
|
}
|
|
81
|
+
function isCanopyTokenError(error) {
|
|
82
|
+
return error instanceof Error && error.name === "CanopyTokenError";
|
|
83
|
+
}
|
|
41
84
|
|
|
42
85
|
// src/client.ts
|
|
43
86
|
function isCursorPagination(pagination) {
|
|
@@ -46,11 +89,14 @@ function isCursorPagination(pagination) {
|
|
|
46
89
|
var DEFAULT_BASE_URL = "https://auth.canopy-io.com";
|
|
47
90
|
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
48
91
|
var DEFAULT_MAX_RETRIES = 2;
|
|
92
|
+
var DEFAULT_MAX_BACKOFF_MS = 3e4;
|
|
49
93
|
var IDEMPOTENT_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "PUT", "DELETE"]);
|
|
94
|
+
var NOT_MODIFIED = 304;
|
|
50
95
|
var CanopyClient = class {
|
|
51
96
|
baseUrl;
|
|
52
97
|
timeoutMs;
|
|
53
98
|
maxRetries;
|
|
99
|
+
maxBackoffMs;
|
|
54
100
|
authHeaders;
|
|
55
101
|
extraHeaders;
|
|
56
102
|
fetchImpl;
|
|
@@ -63,6 +109,7 @@ var CanopyClient = class {
|
|
|
63
109
|
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
64
110
|
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
65
111
|
this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
112
|
+
this.maxBackoffMs = options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
|
|
66
113
|
this.extraHeaders = options.headers ?? {};
|
|
67
114
|
this.fetchImpl = options.fetch ?? globalThis.fetch;
|
|
68
115
|
if (typeof this.fetchImpl !== "function") {
|
|
@@ -84,30 +131,80 @@ var CanopyClient = class {
|
|
|
84
131
|
* `CanopyConnectionError`.
|
|
85
132
|
*/
|
|
86
133
|
async request(method, path, options = {}) {
|
|
134
|
+
const response = await this.perform(method, path, options);
|
|
135
|
+
return this.unwrap(response, method.toUpperCase(), path);
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* A conditional read: send the validator you already hold, and find out
|
|
139
|
+
* whether anything changed.
|
|
140
|
+
*
|
|
141
|
+
* The sibling of `If-Match`, which this client already sends for optimistic
|
|
142
|
+
* concurrency. `304 Not Modified` is a *success* — it means the copy you
|
|
143
|
+
* have is current — but it is not a 2xx, so `request` would raise it as an
|
|
144
|
+
* error. Hence a separate entry point with a return type that says which
|
|
145
|
+
* happened rather than one that has to be inspected.
|
|
146
|
+
*
|
|
147
|
+
* Use it to hold something expensive and revalidate cheaply — the hierarchy
|
|
148
|
+
* behind local authorization is the case this exists for.
|
|
149
|
+
*/
|
|
150
|
+
async requestConditional(method, path, etag, options = {}) {
|
|
151
|
+
const conditional = etag ? { ...options, headers: { ...options.headers, "If-None-Match": etag } } : options;
|
|
152
|
+
const response = await this.perform(
|
|
153
|
+
method,
|
|
154
|
+
path,
|
|
155
|
+
conditional,
|
|
156
|
+
(status) => status === NOT_MODIFIED || status >= 200 && status < 300
|
|
157
|
+
);
|
|
158
|
+
if (response.status === NOT_MODIFIED) {
|
|
159
|
+
return { modified: false };
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
modified: true,
|
|
163
|
+
data: await this.unwrap(response, method.toUpperCase(), path),
|
|
164
|
+
etag: response.headers.get("etag")
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Everything up to the response: retries, backoff, cancellation and the
|
|
169
|
+
* status check, without deciding what the body means.
|
|
170
|
+
*
|
|
171
|
+
* Split out so a conditional read can accept `304` where an ordinary one
|
|
172
|
+
* must not, rather than either duplicating the retry policy or teaching
|
|
173
|
+
* `unwrap` about statuses that carry no body.
|
|
174
|
+
*/
|
|
175
|
+
async perform(method, path, options = {}, accept = (status) => status >= 200 && status < 300) {
|
|
87
176
|
const url = this.buildUrl(path, options.query);
|
|
88
177
|
const upper = method.toUpperCase();
|
|
89
178
|
const retryable = options.idempotent ?? IDEMPOTENT_METHODS.has(upper);
|
|
179
|
+
const maxRetries = options.maxRetries ?? this.maxRetries;
|
|
180
|
+
const maxBackoffMs = options.maxBackoffMs ?? this.maxBackoffMs;
|
|
181
|
+
throwIfAborted(options.signal);
|
|
90
182
|
let lastError;
|
|
91
|
-
for (let attempt = 0; attempt <=
|
|
183
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
92
184
|
if (attempt > 0) {
|
|
93
|
-
await delay(
|
|
185
|
+
await delay(
|
|
186
|
+
backoffMs(attempt, lastError, maxBackoffMs),
|
|
187
|
+
options.signal
|
|
188
|
+
);
|
|
189
|
+
throwIfAborted(options.signal);
|
|
94
190
|
}
|
|
95
191
|
try {
|
|
96
192
|
const response = await this.send(upper, url, options);
|
|
97
|
-
if (this.shouldRetry(response.status, retryable, attempt)) {
|
|
193
|
+
if (this.shouldRetry(response.status, retryable, attempt, maxRetries)) {
|
|
98
194
|
lastError = await this.toError(response, upper, path);
|
|
99
195
|
continue;
|
|
100
196
|
}
|
|
101
|
-
if (!response.
|
|
197
|
+
if (!accept(response.status)) {
|
|
102
198
|
throw await this.toError(response, upper, path);
|
|
103
199
|
}
|
|
104
|
-
return
|
|
200
|
+
return response;
|
|
105
201
|
} catch (error) {
|
|
106
202
|
if (error instanceof CanopyError) {
|
|
107
203
|
throw error;
|
|
108
204
|
}
|
|
109
205
|
lastError = error;
|
|
110
|
-
|
|
206
|
+
throwIfAborted(options.signal);
|
|
207
|
+
if (!retryable || attempt === maxRetries) {
|
|
111
208
|
throw new CanopyConnectionError(
|
|
112
209
|
`${upper} ${path} failed: ${describe(error)}`,
|
|
113
210
|
{ method: upper, path },
|
|
@@ -117,13 +214,13 @@ var CanopyClient = class {
|
|
|
117
214
|
}
|
|
118
215
|
}
|
|
119
216
|
throw new CanopyConnectionError(
|
|
120
|
-
`${upper} ${path} exhausted ${
|
|
217
|
+
`${upper} ${path} exhausted ${maxRetries + 1} attempts`,
|
|
121
218
|
{ method: upper, path },
|
|
122
219
|
{ cause: lastError }
|
|
123
220
|
);
|
|
124
221
|
}
|
|
125
|
-
shouldRetry(status, retryable, attempt) {
|
|
126
|
-
if (attempt >=
|
|
222
|
+
shouldRetry(status, retryable, attempt, maxRetries) {
|
|
223
|
+
if (attempt >= maxRetries) {
|
|
127
224
|
return false;
|
|
128
225
|
}
|
|
129
226
|
if (status === 429) {
|
|
@@ -133,17 +230,21 @@ var CanopyClient = class {
|
|
|
133
230
|
}
|
|
134
231
|
async send(method, url, options) {
|
|
135
232
|
const controller = new AbortController();
|
|
136
|
-
const
|
|
233
|
+
const timeoutMs = options.timeoutMs ?? this.timeoutMs;
|
|
234
|
+
const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : void 0;
|
|
137
235
|
const onAbort = () => controller.abort();
|
|
236
|
+
if (options.signal?.aborted) {
|
|
237
|
+
controller.abort();
|
|
238
|
+
}
|
|
138
239
|
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
139
240
|
const headers = {
|
|
140
241
|
Accept: "application/json",
|
|
141
|
-
...this.extraHeaders
|
|
142
|
-
...this.authHeaders
|
|
242
|
+
...this.extraHeaders
|
|
143
243
|
};
|
|
144
244
|
if (options.body !== void 0) {
|
|
145
245
|
headers["Content-Type"] = "application/json";
|
|
146
246
|
}
|
|
247
|
+
Object.assign(headers, options.headers, this.authHeaders);
|
|
147
248
|
const init = { method, headers, signal: controller.signal };
|
|
148
249
|
if (options.body !== void 0) {
|
|
149
250
|
init.body = JSON.stringify(options.body);
|
|
@@ -170,7 +271,7 @@ var CanopyClient = class {
|
|
|
170
271
|
}
|
|
171
272
|
return url.toString();
|
|
172
273
|
}
|
|
173
|
-
async unwrap(response) {
|
|
274
|
+
async unwrap(response, method, path) {
|
|
174
275
|
if (response.status === 204) {
|
|
175
276
|
return void 0;
|
|
176
277
|
}
|
|
@@ -178,7 +279,19 @@ var CanopyClient = class {
|
|
|
178
279
|
if (text === "") {
|
|
179
280
|
return void 0;
|
|
180
281
|
}
|
|
181
|
-
|
|
282
|
+
let parsed;
|
|
283
|
+
try {
|
|
284
|
+
parsed = JSON.parse(text);
|
|
285
|
+
} catch {
|
|
286
|
+
throw new CanopyError(
|
|
287
|
+
{
|
|
288
|
+
statusCode: response.status,
|
|
289
|
+
code: null,
|
|
290
|
+
message: `${method} ${path} returned ${response.status} with a body that is not JSON.`
|
|
291
|
+
},
|
|
292
|
+
{ method, path }
|
|
293
|
+
);
|
|
294
|
+
}
|
|
182
295
|
if (parsed && typeof parsed === "object" && "data" in parsed) {
|
|
183
296
|
return parsed["data"];
|
|
184
297
|
}
|
|
@@ -201,14 +314,7 @@ var CanopyClient = class {
|
|
|
201
314
|
}
|
|
202
315
|
} catch {
|
|
203
316
|
}
|
|
204
|
-
|
|
205
|
-
if (retryAfter !== null) {
|
|
206
|
-
Object.defineProperty(error, "retryAfterMs", {
|
|
207
|
-
value: retryAfter,
|
|
208
|
-
enumerable: true
|
|
209
|
-
});
|
|
210
|
-
}
|
|
211
|
-
return error;
|
|
317
|
+
return new CanopyError(body, { method, path }, retryAfter ?? void 0);
|
|
212
318
|
}
|
|
213
319
|
};
|
|
214
320
|
function parseRetryAfter(header) {
|
|
@@ -225,20 +331,46 @@ function parseRetryAfter(header) {
|
|
|
225
331
|
}
|
|
226
332
|
return Math.max(0, date - Date.now());
|
|
227
333
|
}
|
|
228
|
-
function backoffMs(attempt, lastError) {
|
|
334
|
+
function backoffMs(attempt, lastError, maxBackoffMs) {
|
|
229
335
|
const advised = lastError && typeof lastError === "object" && "retryAfterMs" in lastError ? Number(lastError.retryAfterMs) : NaN;
|
|
230
336
|
if (Number.isFinite(advised)) {
|
|
231
|
-
return advised;
|
|
337
|
+
return Math.min(advised, maxBackoffMs);
|
|
232
338
|
}
|
|
233
339
|
const base = 250 * 2 ** (attempt - 1);
|
|
234
|
-
return base + Math.random() * base;
|
|
340
|
+
return Math.min(base + Math.random() * base, maxBackoffMs);
|
|
341
|
+
}
|
|
342
|
+
function throwIfAborted(signal) {
|
|
343
|
+
if (!signal?.aborted) {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
throw signal.reason ?? new DOMException("This operation was aborted", "AbortError");
|
|
235
347
|
}
|
|
236
|
-
function delay(ms) {
|
|
237
|
-
|
|
348
|
+
function delay(ms, signal) {
|
|
349
|
+
if (signal?.aborted) {
|
|
350
|
+
return Promise.resolve();
|
|
351
|
+
}
|
|
352
|
+
return new Promise((resolve) => {
|
|
353
|
+
const state = { settled: false };
|
|
354
|
+
const finish = () => {
|
|
355
|
+
if (state.settled) {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
state.settled = true;
|
|
359
|
+
if (state.timer !== void 0) {
|
|
360
|
+
clearTimeout(state.timer);
|
|
361
|
+
}
|
|
362
|
+
signal?.removeEventListener("abort", finish);
|
|
363
|
+
resolve();
|
|
364
|
+
};
|
|
365
|
+
state.timer = setTimeout(finish, ms);
|
|
366
|
+
if (!state.settled) {
|
|
367
|
+
signal?.addEventListener("abort", finish, { once: true });
|
|
368
|
+
}
|
|
369
|
+
});
|
|
238
370
|
}
|
|
239
371
|
function describe(error) {
|
|
240
372
|
if (error instanceof Error) {
|
|
241
|
-
return error.name === "AbortError" ? "timed out
|
|
373
|
+
return error.name === "AbortError" ? "timed out" : error.message;
|
|
242
374
|
}
|
|
243
375
|
return String(error);
|
|
244
376
|
}
|
|
@@ -441,11 +573,25 @@ var Identities = class {
|
|
|
441
573
|
`/api/v1/identities/${encodeURIComponent(id)}/activate`
|
|
442
574
|
);
|
|
443
575
|
}
|
|
444
|
-
/**
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
576
|
+
/**
|
|
577
|
+
* Every role this identity holds, and where — across all pages.
|
|
578
|
+
*
|
|
579
|
+
* Paginated (20 per page by default), so this returns a `Paginator` rather
|
|
580
|
+
* than one response. Reading a single page here would under-report what an
|
|
581
|
+
* identity can do, which is the dangerous direction to be wrong in.
|
|
582
|
+
*
|
|
583
|
+
* ```ts
|
|
584
|
+
* for await (const assignment of canopy.identities.assignments(id)) { … }
|
|
585
|
+
* ```
|
|
586
|
+
*/
|
|
587
|
+
assignments(id, query = {}) {
|
|
588
|
+
return paginate(
|
|
589
|
+
(params) => this.client.request(
|
|
590
|
+
"GET",
|
|
591
|
+
`/api/v1/identities/${encodeURIComponent(id)}/assignments`,
|
|
592
|
+
{ query: params }
|
|
593
|
+
),
|
|
594
|
+
{ ...query }
|
|
449
595
|
);
|
|
450
596
|
}
|
|
451
597
|
/**
|
|
@@ -463,6 +609,14 @@ var Identities = class {
|
|
|
463
609
|
}
|
|
464
610
|
};
|
|
465
611
|
|
|
612
|
+
// src/schema.ts
|
|
613
|
+
function withConcurrency(options = {}) {
|
|
614
|
+
if (options.ifMatch === void 0) {
|
|
615
|
+
return {};
|
|
616
|
+
}
|
|
617
|
+
return { headers: { "If-Match": options.ifMatch } };
|
|
618
|
+
}
|
|
619
|
+
|
|
466
620
|
// src/resources/permissions.ts
|
|
467
621
|
var Permissions = class {
|
|
468
622
|
constructor(client) {
|
|
@@ -478,10 +632,19 @@ var Permissions = class {
|
|
|
478
632
|
* `effective_node_id: null` — that answer must never be used to guard a
|
|
479
633
|
* resource that belongs to a specific node, which is why the scope is a
|
|
480
634
|
* required field rather than a default.
|
|
635
|
+
*
|
|
636
|
+
* This runs on the request path, so it is the call most worth passing
|
|
637
|
+
* `signal` and a tight `timeoutMs` to: without them a slow answer here holds
|
|
638
|
+
* an inbound request open for the client-wide deadline on every attempt.
|
|
481
639
|
*/
|
|
482
|
-
evaluate(input) {
|
|
640
|
+
evaluate(input, options = {}) {
|
|
483
641
|
return this.client.request("POST", "/api/v1/permissions/evaluate", {
|
|
484
|
-
body: input
|
|
642
|
+
body: input,
|
|
643
|
+
// A POST only because the question travels in a body; it computes a
|
|
644
|
+
// decision and writes nothing, so repeating it is safe — and this is the
|
|
645
|
+
// call least able to afford giving up on a transient 5xx.
|
|
646
|
+
idempotent: true,
|
|
647
|
+
...options
|
|
485
648
|
});
|
|
486
649
|
}
|
|
487
650
|
/**
|
|
@@ -490,9 +653,12 @@ var Permissions = class {
|
|
|
490
653
|
* Prefer this to a loop over `evaluate` when rendering a screen: the checks
|
|
491
654
|
* are answered together instead of paying request latency for each.
|
|
492
655
|
*/
|
|
493
|
-
evaluateBulk(input) {
|
|
656
|
+
evaluateBulk(input, options = {}) {
|
|
494
657
|
return this.client.request("POST", "/api/v1/permissions/evaluate/bulk", {
|
|
495
|
-
body: input
|
|
658
|
+
body: input,
|
|
659
|
+
/** Read-only, like `evaluate`. */
|
|
660
|
+
idempotent: true,
|
|
661
|
+
...options
|
|
496
662
|
});
|
|
497
663
|
}
|
|
498
664
|
/**
|
|
@@ -500,9 +666,12 @@ var Permissions = class {
|
|
|
500
666
|
* it was inherited from. For debugging an unexpected allow or deny, not for
|
|
501
667
|
* the enforcement path.
|
|
502
668
|
*/
|
|
503
|
-
explain(input) {
|
|
669
|
+
explain(input, options = {}) {
|
|
504
670
|
return this.client.request("POST", "/api/v1/permissions/evaluate/explain", {
|
|
505
|
-
body: input
|
|
671
|
+
body: input,
|
|
672
|
+
/** Read-only, like `evaluate`. */
|
|
673
|
+
idempotent: true,
|
|
674
|
+
...options
|
|
506
675
|
});
|
|
507
676
|
}
|
|
508
677
|
/** Every permission in the Environment, page by page. */
|
|
@@ -522,17 +691,23 @@ var Permissions = class {
|
|
|
522
691
|
create(input) {
|
|
523
692
|
return this.client.request("POST", "/api/v1/permissions", { body: input });
|
|
524
693
|
}
|
|
525
|
-
|
|
694
|
+
/**
|
|
695
|
+
* Pass `ifMatch` with the permission's current `version` to make a
|
|
696
|
+
* read-modify-write safe — a concurrent edit answers 409 instead of being
|
|
697
|
+
* silently overwritten.
|
|
698
|
+
*/
|
|
699
|
+
update(id, input, options = {}) {
|
|
526
700
|
return this.client.request(
|
|
527
701
|
"PATCH",
|
|
528
702
|
`/api/v1/permissions/${encodeURIComponent(id)}`,
|
|
529
|
-
{ body: input }
|
|
703
|
+
{ body: input, ...withConcurrency(options) }
|
|
530
704
|
);
|
|
531
705
|
}
|
|
532
|
-
delete(id) {
|
|
706
|
+
delete(id, options = {}) {
|
|
533
707
|
return this.client.request(
|
|
534
708
|
"DELETE",
|
|
535
|
-
`/api/v1/permissions/${encodeURIComponent(id)}
|
|
709
|
+
`/api/v1/permissions/${encodeURIComponent(id)}`,
|
|
710
|
+
withConcurrency(options)
|
|
536
711
|
);
|
|
537
712
|
}
|
|
538
713
|
};
|
|
@@ -558,17 +733,23 @@ var Roles = class {
|
|
|
558
733
|
create(input) {
|
|
559
734
|
return this.client.request("POST", "/api/v1/roles", { body: input });
|
|
560
735
|
}
|
|
561
|
-
|
|
736
|
+
/**
|
|
737
|
+
* Pass `ifMatch` with the role's current `version` to make a
|
|
738
|
+
* read-modify-write safe — a concurrent edit answers 409 instead of being
|
|
739
|
+
* silently overwritten.
|
|
740
|
+
*/
|
|
741
|
+
update(id, input, options = {}) {
|
|
562
742
|
return this.client.request(
|
|
563
743
|
"PATCH",
|
|
564
744
|
`/api/v1/roles/${encodeURIComponent(id)}`,
|
|
565
|
-
{ body: input }
|
|
745
|
+
{ body: input, ...withConcurrency(options) }
|
|
566
746
|
);
|
|
567
747
|
}
|
|
568
|
-
delete(id) {
|
|
748
|
+
delete(id, options = {}) {
|
|
569
749
|
return this.client.request(
|
|
570
750
|
"DELETE",
|
|
571
|
-
`/api/v1/roles/${encodeURIComponent(id)}
|
|
751
|
+
`/api/v1/roles/${encodeURIComponent(id)}`,
|
|
752
|
+
withConcurrency(options)
|
|
572
753
|
);
|
|
573
754
|
}
|
|
574
755
|
permissions(id) {
|
|
@@ -606,6 +787,537 @@ var Canopy = class {
|
|
|
606
787
|
}
|
|
607
788
|
};
|
|
608
789
|
|
|
609
|
-
|
|
790
|
+
// src/authorizer.ts
|
|
791
|
+
var DEFAULT_TTL_MS = 6e4;
|
|
792
|
+
var MAX_WALK_DEPTH = 256;
|
|
793
|
+
var LocalAuthorizer = class {
|
|
794
|
+
client;
|
|
795
|
+
ttlMs;
|
|
796
|
+
now;
|
|
797
|
+
readOptions;
|
|
798
|
+
grants = /* @__PURE__ */ new Map();
|
|
799
|
+
tree;
|
|
800
|
+
/**
|
|
801
|
+
* In-flight reads, so concurrent requests for the same thing share one call.
|
|
802
|
+
*
|
|
803
|
+
* Without this a cold start under load fans out: a hundred simultaneous
|
|
804
|
+
* requests for one identity would each miss the cache and each fetch, which
|
|
805
|
+
* is the per-request traffic this class exists to remove, concentrated into
|
|
806
|
+
* the worst possible moment.
|
|
807
|
+
*/
|
|
808
|
+
pendingGrants = /* @__PURE__ */ new Map();
|
|
809
|
+
pendingTree;
|
|
810
|
+
stats = {
|
|
811
|
+
grantFetches: 0,
|
|
812
|
+
treeRequests: 0,
|
|
813
|
+
treeNotModified: 0
|
|
814
|
+
};
|
|
815
|
+
constructor(client, options = {}) {
|
|
816
|
+
this.client = client;
|
|
817
|
+
this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
818
|
+
this.now = options.now ?? (() => Date.now());
|
|
819
|
+
this.readOptions = {
|
|
820
|
+
...options.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs },
|
|
821
|
+
...options.maxRetries === void 0 ? {} : { maxRetries: options.maxRetries, maxBackoffMs: options.timeoutMs }
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Whether the identity holds the permission.
|
|
826
|
+
*
|
|
827
|
+
* Shaped like the API's own evaluate so a caller can swap one for the other.
|
|
828
|
+
* A `node` check with no node is a denial rather than an error: a request
|
|
829
|
+
* whose subject cannot be established is exactly the one that must not pass.
|
|
830
|
+
*/
|
|
831
|
+
async evaluate(query, options = {}) {
|
|
832
|
+
if (!options.signal) {
|
|
833
|
+
return this.decide(query);
|
|
834
|
+
}
|
|
835
|
+
const signal = options.signal;
|
|
836
|
+
let onAbort;
|
|
837
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
838
|
+
const fail = () => {
|
|
839
|
+
reject(
|
|
840
|
+
signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason ?? "aborted"))
|
|
841
|
+
);
|
|
842
|
+
};
|
|
843
|
+
if (signal.aborted) {
|
|
844
|
+
fail();
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
onAbort = fail;
|
|
848
|
+
signal.addEventListener("abort", fail, { once: true });
|
|
849
|
+
});
|
|
850
|
+
return Promise.race([this.decide(query), aborted]).finally(() => {
|
|
851
|
+
if (onAbort) {
|
|
852
|
+
signal.removeEventListener("abort", onAbort);
|
|
853
|
+
}
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
async decide(query) {
|
|
857
|
+
const roots = await this.grantRootsFor(query.identity_id);
|
|
858
|
+
const granted = roots.get(query.permission);
|
|
859
|
+
if (!granted || granted.size === 0) {
|
|
860
|
+
return { allowed: false };
|
|
861
|
+
}
|
|
862
|
+
if ((query.scope ?? "node") === "app_wide") {
|
|
863
|
+
return { allowed: true };
|
|
864
|
+
}
|
|
865
|
+
if (!query.node_id) {
|
|
866
|
+
return { allowed: false };
|
|
867
|
+
}
|
|
868
|
+
return { allowed: await this.holdsAtNode(granted, query.node_id) };
|
|
869
|
+
}
|
|
870
|
+
/** Counters for observability — how much traffic the cache is actually saving. */
|
|
871
|
+
snapshot() {
|
|
872
|
+
return { ...this.stats };
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Drop what is held so the next evaluate refetches.
|
|
876
|
+
*
|
|
877
|
+
* With an `identityId`, only that identity's grants are dropped — the
|
|
878
|
+
* cached hierarchy and every other identity's entries stay warm. This is
|
|
879
|
+
* the shape an assignment webhook wants: the event names the identity
|
|
880
|
+
* whose authority moved, and nothing else needs to pay a refetch for it.
|
|
881
|
+
*
|
|
882
|
+
* With no argument, everything goes: grants and the hierarchy tree. Not
|
|
883
|
+
* needed in normal operation, where entries expire on their own; useful in
|
|
884
|
+
* tests and after a change whose reach you cannot name (a role's
|
|
885
|
+
* permissions edited, a node moved).
|
|
886
|
+
*
|
|
887
|
+
* Multi-instance honesty: an invalidation reaches THIS process only. A
|
|
888
|
+
* webhook lands on one instance behind a load balancer; the others serve
|
|
889
|
+
* their cached grants until their own TTL expires. Unless the app fans the
|
|
890
|
+
* event out over its own pub/sub, the fleet-wide revocation guarantee is
|
|
891
|
+
* the TTL, and webhook-driven invalidation is a latency optimization on
|
|
892
|
+
* top of it — size the TTL to the revocation latency you can promise.
|
|
893
|
+
*/
|
|
894
|
+
invalidate(identityId) {
|
|
895
|
+
if (identityId !== void 0) {
|
|
896
|
+
this.grants.delete(identityId);
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
this.grants.clear();
|
|
900
|
+
this.tree = void 0;
|
|
901
|
+
}
|
|
902
|
+
/** Climb from `nodeId` and look for a grant root among its ancestors. */
|
|
903
|
+
async holdsAtNode(granted, nodeId) {
|
|
904
|
+
if (granted.has(nodeId)) {
|
|
905
|
+
return true;
|
|
906
|
+
}
|
|
907
|
+
const parents = await this.parents();
|
|
908
|
+
if (!parents.has(nodeId)) {
|
|
909
|
+
throw new CanopyAuthorizerError(
|
|
910
|
+
"authorizer.hierarchy_incomplete",
|
|
911
|
+
`The hierarchy this client can read does not contain node "${nodeId}", so authorization at that node cannot be decided. A scoped API key needs the \`hierarchy.view\` scope to read the tree.`
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
let current = parents.get(nodeId);
|
|
915
|
+
let depth = 0;
|
|
916
|
+
while (current && depth < MAX_WALK_DEPTH) {
|
|
917
|
+
if (granted.has(current)) {
|
|
918
|
+
return true;
|
|
919
|
+
}
|
|
920
|
+
current = parents.get(current);
|
|
921
|
+
depth += 1;
|
|
922
|
+
}
|
|
923
|
+
return false;
|
|
924
|
+
}
|
|
925
|
+
async grantRootsFor(identityId) {
|
|
926
|
+
const cached = this.grants.get(identityId);
|
|
927
|
+
if (cached && cached.expiresAt > this.now()) {
|
|
928
|
+
return cached.roots;
|
|
929
|
+
}
|
|
930
|
+
const pending = this.pendingGrants.get(identityId);
|
|
931
|
+
if (pending) {
|
|
932
|
+
return pending;
|
|
933
|
+
}
|
|
934
|
+
const read = this.fetchGrantRoots(identityId).finally(() => {
|
|
935
|
+
this.pendingGrants.delete(identityId);
|
|
936
|
+
});
|
|
937
|
+
this.pendingGrants.set(identityId, read);
|
|
938
|
+
return read;
|
|
939
|
+
}
|
|
940
|
+
async fetchGrantRoots(identityId) {
|
|
941
|
+
this.stats.grantFetches += 1;
|
|
942
|
+
const response = await this.client.request(
|
|
943
|
+
"GET",
|
|
944
|
+
`/api/v1/identities/${encodeURIComponent(identityId)}/grants`,
|
|
945
|
+
this.readOptions
|
|
946
|
+
);
|
|
947
|
+
const roots = /* @__PURE__ */ new Map();
|
|
948
|
+
for (const row of response.items ?? []) {
|
|
949
|
+
roots.set(row.permission, new Set(row.nodes));
|
|
950
|
+
}
|
|
951
|
+
this.grants.set(identityId, {
|
|
952
|
+
roots,
|
|
953
|
+
expiresAt: this.now() + this.ttlMs
|
|
954
|
+
});
|
|
955
|
+
return roots;
|
|
956
|
+
}
|
|
957
|
+
async parents() {
|
|
958
|
+
if (this.tree && this.tree.expiresAt > this.now()) {
|
|
959
|
+
return this.tree.parents;
|
|
960
|
+
}
|
|
961
|
+
if (this.pendingTree) {
|
|
962
|
+
return this.pendingTree;
|
|
963
|
+
}
|
|
964
|
+
const read = this.fetchTree().finally(() => {
|
|
965
|
+
this.pendingTree = void 0;
|
|
966
|
+
});
|
|
967
|
+
this.pendingTree = read;
|
|
968
|
+
return read;
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* Revalidate rather than re-read. The hierarchy is the expensive half and
|
|
972
|
+
* the one that changes least, so the common case is a `304` and no transfer
|
|
973
|
+
* at all — the tree stays in memory and only its expiry moves.
|
|
974
|
+
*/
|
|
975
|
+
async fetchTree() {
|
|
976
|
+
this.stats.treeRequests += 1;
|
|
977
|
+
const held = this.tree;
|
|
978
|
+
const result = await this.client.requestConditional(
|
|
979
|
+
"GET",
|
|
980
|
+
"/api/v1/nodes/parents",
|
|
981
|
+
held?.etag ?? void 0,
|
|
982
|
+
this.readOptions
|
|
983
|
+
);
|
|
984
|
+
if (!result.modified && held) {
|
|
985
|
+
this.stats.treeNotModified += 1;
|
|
986
|
+
this.tree = { ...held, expiresAt: this.now() + this.ttlMs };
|
|
987
|
+
return held.parents;
|
|
988
|
+
}
|
|
989
|
+
const parents = /* @__PURE__ */ new Map();
|
|
990
|
+
if (result.modified) {
|
|
991
|
+
for (const edge of result.data.items) {
|
|
992
|
+
parents.set(edge.id, edge.parent_node_id ?? null);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
this.tree = {
|
|
996
|
+
parents,
|
|
997
|
+
etag: result.modified ? result.etag : held?.etag ?? null,
|
|
998
|
+
expiresAt: this.now() + this.ttlMs
|
|
999
|
+
};
|
|
1000
|
+
return parents;
|
|
1001
|
+
}
|
|
1002
|
+
};
|
|
1003
|
+
|
|
1004
|
+
// src/verify.ts
|
|
1005
|
+
var DEFAULT_ISSUER = "https://auth.canopy-io.com";
|
|
1006
|
+
var DEFAULT_JWKS_CACHE_MAX_AGE_MS = 10 * 60 * 1e3;
|
|
1007
|
+
var DEFAULT_JWKS_MIN_REFETCH_INTERVAL_MS = 30 * 1e3;
|
|
1008
|
+
var DEFAULT_JWKS_TIMEOUT_MS = 5e3;
|
|
1009
|
+
var PRINCIPAL_TYPES = /* @__PURE__ */ new Set(["user", "identity", "api_key", "platform"]);
|
|
1010
|
+
var DEFAULT_CLOCK_TOLERANCE_SEC = 60;
|
|
1011
|
+
function decodeBase64Url(value) {
|
|
1012
|
+
const padded = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
1013
|
+
const binary = atob(padded.padEnd(Math.ceil(padded.length / 4) * 4, "="));
|
|
1014
|
+
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
|
|
1015
|
+
for (let i = 0; i < binary.length; i++) {
|
|
1016
|
+
bytes[i] = binary.charCodeAt(i);
|
|
1017
|
+
}
|
|
1018
|
+
return bytes;
|
|
1019
|
+
}
|
|
1020
|
+
function decodeJsonSegment(segment, what) {
|
|
1021
|
+
try {
|
|
1022
|
+
return JSON.parse(new TextDecoder().decode(decodeBase64Url(segment)));
|
|
1023
|
+
} catch (cause) {
|
|
1024
|
+
throw new CanopyTokenError(
|
|
1025
|
+
"token.malformed",
|
|
1026
|
+
`Token ${what} is not valid base64url-encoded JSON.`,
|
|
1027
|
+
{ cause }
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
var TokenVerifier = class {
|
|
1032
|
+
issuer;
|
|
1033
|
+
audience;
|
|
1034
|
+
jwksUri;
|
|
1035
|
+
jwksCacheMaxAgeMs;
|
|
1036
|
+
jwksMinRefetchIntervalMs;
|
|
1037
|
+
jwksTimeoutMs;
|
|
1038
|
+
clockToleranceSec;
|
|
1039
|
+
allowPreAuthTokens;
|
|
1040
|
+
fetchImpl;
|
|
1041
|
+
/** Imported keys by `kid`, so a repeat verification skips the import cost. */
|
|
1042
|
+
keys = /* @__PURE__ */ new Map();
|
|
1043
|
+
keysFetchedAt = 0;
|
|
1044
|
+
lastFetchAttemptAt = 0;
|
|
1045
|
+
/** In-flight fetch, so a burst of requests triggers one call, not N. */
|
|
1046
|
+
inFlight = null;
|
|
1047
|
+
constructor(options = {}) {
|
|
1048
|
+
this.issuer = (options.issuer ?? DEFAULT_ISSUER).replace(/\/+$/, "");
|
|
1049
|
+
this.audience = options.audience;
|
|
1050
|
+
this.jwksUri = options.jwksUri ?? `${this.issuer}/.well-known/jwks.json`;
|
|
1051
|
+
this.jwksCacheMaxAgeMs = options.jwksCacheMaxAgeMs ?? DEFAULT_JWKS_CACHE_MAX_AGE_MS;
|
|
1052
|
+
this.jwksMinRefetchIntervalMs = options.jwksMinRefetchIntervalMs ?? DEFAULT_JWKS_MIN_REFETCH_INTERVAL_MS;
|
|
1053
|
+
this.jwksTimeoutMs = options.jwksTimeoutMs ?? DEFAULT_JWKS_TIMEOUT_MS;
|
|
1054
|
+
this.clockToleranceSec = options.clockToleranceSec ?? DEFAULT_CLOCK_TOLERANCE_SEC;
|
|
1055
|
+
this.allowPreAuthTokens = options.allowPreAuthTokens ?? false;
|
|
1056
|
+
const boundFetch = options.fetch ?? globalThis.fetch;
|
|
1057
|
+
if (typeof boundFetch !== "function") {
|
|
1058
|
+
throw new TypeError(
|
|
1059
|
+
"TokenVerifier requires a fetch implementation. Pass `fetch` on Node runtimes without a global one."
|
|
1060
|
+
);
|
|
1061
|
+
}
|
|
1062
|
+
this.fetchImpl = boundFetch.bind(globalThis);
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* Verify a token and return its claims. Throws {@link CanopyTokenError} on
|
|
1066
|
+
* anything short of a full pass — branch on `error.code`.
|
|
1067
|
+
*
|
|
1068
|
+
* Order matters: the signature is checked before any claim is believed, so
|
|
1069
|
+
* nothing downstream ever reads an unverified payload.
|
|
1070
|
+
*/
|
|
1071
|
+
async verify(token) {
|
|
1072
|
+
const parts = token.split(".");
|
|
1073
|
+
if (parts.length !== 3) {
|
|
1074
|
+
throw new CanopyTokenError(
|
|
1075
|
+
"token.malformed",
|
|
1076
|
+
"Token is not a three-part JWS."
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
const [encodedHeader, encodedPayload, encodedSignature] = parts;
|
|
1080
|
+
const header = decodeJsonSegment(encodedHeader, "header");
|
|
1081
|
+
if (header.alg !== "RS256") {
|
|
1082
|
+
throw new CanopyTokenError(
|
|
1083
|
+
"token.unsupported_algorithm",
|
|
1084
|
+
`Token is signed with "${header.alg ?? "none"}"; Canopy issues RS256.`
|
|
1085
|
+
);
|
|
1086
|
+
}
|
|
1087
|
+
const key = await this.resolveKey(header.kid);
|
|
1088
|
+
const signed = new TextEncoder().encode(
|
|
1089
|
+
`${encodedHeader}.${encodedPayload}`
|
|
1090
|
+
);
|
|
1091
|
+
let signatureValid;
|
|
1092
|
+
try {
|
|
1093
|
+
signatureValid = await crypto.subtle.verify(
|
|
1094
|
+
"RSASSA-PKCS1-v1_5",
|
|
1095
|
+
key,
|
|
1096
|
+
decodeBase64Url(encodedSignature),
|
|
1097
|
+
signed
|
|
1098
|
+
);
|
|
1099
|
+
} catch (cause) {
|
|
1100
|
+
throw new CanopyTokenError(
|
|
1101
|
+
"token.malformed",
|
|
1102
|
+
"Token signature is not decodable.",
|
|
1103
|
+
{ cause }
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
if (!signatureValid) {
|
|
1107
|
+
throw new CanopyTokenError(
|
|
1108
|
+
"token.signature_invalid",
|
|
1109
|
+
"Token signature does not match Canopy's signing key."
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
const claims = decodeJsonSegment(
|
|
1113
|
+
encodedPayload,
|
|
1114
|
+
"payload"
|
|
1115
|
+
);
|
|
1116
|
+
this.assertClaims(claims);
|
|
1117
|
+
return claims;
|
|
1118
|
+
}
|
|
1119
|
+
/** Everything checked after the signature is known good. */
|
|
1120
|
+
assertClaims(claims) {
|
|
1121
|
+
if (typeof claims.sub !== "string" || claims.sub === "") {
|
|
1122
|
+
throw new CanopyTokenError(
|
|
1123
|
+
"token.malformed",
|
|
1124
|
+
"Token has no `sub` claim, so it identifies no one."
|
|
1125
|
+
);
|
|
1126
|
+
}
|
|
1127
|
+
if (!PRINCIPAL_TYPES.has(claims.type)) {
|
|
1128
|
+
throw new CanopyTokenError(
|
|
1129
|
+
"token.malformed",
|
|
1130
|
+
`Token has an unrecognised \`type\` claim: ${JSON.stringify(claims.type)}.`
|
|
1131
|
+
);
|
|
1132
|
+
}
|
|
1133
|
+
if (claims.iss !== this.issuer) {
|
|
1134
|
+
throw new CanopyTokenError(
|
|
1135
|
+
"token.issuer_mismatch",
|
|
1136
|
+
`Token was issued by "${String(claims.iss)}", not "${this.issuer}".`
|
|
1137
|
+
);
|
|
1138
|
+
}
|
|
1139
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1140
|
+
if (typeof claims.exp !== "number") {
|
|
1141
|
+
throw new CanopyTokenError(
|
|
1142
|
+
"token.malformed",
|
|
1143
|
+
"Token has no `exp` claim."
|
|
1144
|
+
);
|
|
1145
|
+
}
|
|
1146
|
+
if (now > claims.exp + this.clockToleranceSec) {
|
|
1147
|
+
throw new CanopyTokenError("token.expired", "Token has expired.");
|
|
1148
|
+
}
|
|
1149
|
+
if (typeof claims.nbf === "number" && now < claims.nbf - this.clockToleranceSec) {
|
|
1150
|
+
throw new CanopyTokenError(
|
|
1151
|
+
"token.not_yet_valid",
|
|
1152
|
+
"Token is not valid yet."
|
|
1153
|
+
);
|
|
1154
|
+
}
|
|
1155
|
+
this.assertAudience(claims);
|
|
1156
|
+
if (claims.token_type === "preauth" && !this.allowPreAuthTokens) {
|
|
1157
|
+
throw new CanopyTokenError(
|
|
1158
|
+
"token.preauth_not_allowed",
|
|
1159
|
+
"This is a pre-auth token: the user authenticated but has not selected an Account, so it grants no access. Set `allowPreAuthTokens: true` only if you are building the account picker."
|
|
1160
|
+
);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* `aud` is checked when either side mentions it.
|
|
1165
|
+
*
|
|
1166
|
+
* The case worth stating: a token carries `aud` but the verifier was not
|
|
1167
|
+
* configured with one. That is not "no audience to check" — it is an OAuth
|
|
1168
|
+
* token being verified by something that never said which client it is, and
|
|
1169
|
+
* ignoring it would accept a token minted for a different client. So it
|
|
1170
|
+
* throws and names the option.
|
|
1171
|
+
*/
|
|
1172
|
+
assertAudience(claims) {
|
|
1173
|
+
const audiences = claims.aud === void 0 ? [] : Array.isArray(claims.aud) ? claims.aud : [claims.aud];
|
|
1174
|
+
if (this.audience === void 0) {
|
|
1175
|
+
if (audiences.length > 0) {
|
|
1176
|
+
throw new CanopyTokenError(
|
|
1177
|
+
"token.audience_unverified",
|
|
1178
|
+
"Token carries an `aud` claim but the verifier has no `audience` configured. Set `audience` to your OAuth client id; Direct API tokens carry no audience and need no option."
|
|
1179
|
+
);
|
|
1180
|
+
}
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
if (!audiences.includes(this.audience)) {
|
|
1184
|
+
throw new CanopyTokenError(
|
|
1185
|
+
"token.audience_mismatch",
|
|
1186
|
+
`Token audience does not include "${this.audience}".`
|
|
1187
|
+
);
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
/**
|
|
1191
|
+
* The signing key for a `kid`, fetching the key set when it is stale or when
|
|
1192
|
+
* the `kid` is unknown — the latter is how key rotation is picked up
|
|
1193
|
+
* mid-process, bounded by `jwksMinRefetchIntervalMs`.
|
|
1194
|
+
*/
|
|
1195
|
+
async resolveKey(kid) {
|
|
1196
|
+
if (kid === void 0) {
|
|
1197
|
+
throw new CanopyTokenError(
|
|
1198
|
+
"token.malformed",
|
|
1199
|
+
"Token header has no `kid`, so its signing key cannot be identified."
|
|
1200
|
+
);
|
|
1201
|
+
}
|
|
1202
|
+
const stale = Date.now() - this.keysFetchedAt > this.jwksCacheMaxAgeMs;
|
|
1203
|
+
if (this.keys.size === 0 || stale) {
|
|
1204
|
+
if (this.shouldRefresh()) {
|
|
1205
|
+
await this.refreshKeys();
|
|
1206
|
+
} else if (this.keys.size === 0) {
|
|
1207
|
+
throw new CanopyTokenError(
|
|
1208
|
+
"token.jwks_unavailable",
|
|
1209
|
+
`Signing keys at ${this.jwksUri} are unavailable; backing off before retrying.`
|
|
1210
|
+
);
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
const cached = this.keys.get(kid);
|
|
1214
|
+
if (cached) {
|
|
1215
|
+
return cached;
|
|
1216
|
+
}
|
|
1217
|
+
if (this.shouldRefresh()) {
|
|
1218
|
+
await this.refreshKeys();
|
|
1219
|
+
}
|
|
1220
|
+
const rotated = this.keys.get(kid);
|
|
1221
|
+
if (rotated) {
|
|
1222
|
+
return rotated;
|
|
1223
|
+
}
|
|
1224
|
+
throw new CanopyTokenError(
|
|
1225
|
+
"token.key_not_found",
|
|
1226
|
+
`No signing key matches kid "${kid}".`
|
|
1227
|
+
);
|
|
1228
|
+
}
|
|
1229
|
+
/**
|
|
1230
|
+
* Whether to await a key-set refresh.
|
|
1231
|
+
*
|
|
1232
|
+
* Two ways to qualify, and the first matters as much as the second. A read
|
|
1233
|
+
* already in flight is joined regardless of the floor: it costs no extra
|
|
1234
|
+
* outbound request, and it is what lets a concurrent burst share one fetch
|
|
1235
|
+
* instead of one caller winning and the rest being turned away.
|
|
1236
|
+
*
|
|
1237
|
+
* Otherwise the floor applies — the same one for every refetch path, so they
|
|
1238
|
+
* cannot drift into having different amplification properties.
|
|
1239
|
+
*/
|
|
1240
|
+
shouldRefresh() {
|
|
1241
|
+
if (this.inFlight) {
|
|
1242
|
+
return true;
|
|
1243
|
+
}
|
|
1244
|
+
return Date.now() - this.lastFetchAttemptAt >= this.jwksMinRefetchIntervalMs;
|
|
1245
|
+
}
|
|
1246
|
+
async refreshKeys() {
|
|
1247
|
+
this.inFlight ??= this.fetchKeys().finally(() => {
|
|
1248
|
+
this.inFlight = null;
|
|
1249
|
+
});
|
|
1250
|
+
await this.inFlight;
|
|
1251
|
+
}
|
|
1252
|
+
async fetchKeys() {
|
|
1253
|
+
this.lastFetchAttemptAt = Date.now();
|
|
1254
|
+
let response;
|
|
1255
|
+
const controller = new AbortController();
|
|
1256
|
+
const timer = this.jwksTimeoutMs > 0 ? setTimeout(() => controller.abort(), this.jwksTimeoutMs) : void 0;
|
|
1257
|
+
try {
|
|
1258
|
+
response = await this.fetchImpl(this.jwksUri, {
|
|
1259
|
+
headers: { accept: "application/json" },
|
|
1260
|
+
signal: controller.signal
|
|
1261
|
+
});
|
|
1262
|
+
} catch (cause) {
|
|
1263
|
+
throw new CanopyTokenError(
|
|
1264
|
+
"token.jwks_unavailable",
|
|
1265
|
+
`Could not reach the signing keys at ${this.jwksUri}.`,
|
|
1266
|
+
{ cause }
|
|
1267
|
+
);
|
|
1268
|
+
} finally {
|
|
1269
|
+
clearTimeout(timer);
|
|
1270
|
+
}
|
|
1271
|
+
if (!response.ok) {
|
|
1272
|
+
throw new CanopyTokenError(
|
|
1273
|
+
"token.jwks_unavailable",
|
|
1274
|
+
`Signing keys at ${this.jwksUri} returned HTTP ${response.status}.`
|
|
1275
|
+
);
|
|
1276
|
+
}
|
|
1277
|
+
let document;
|
|
1278
|
+
try {
|
|
1279
|
+
document = await response.json();
|
|
1280
|
+
} catch (cause) {
|
|
1281
|
+
throw new CanopyTokenError(
|
|
1282
|
+
"token.jwks_unavailable",
|
|
1283
|
+
`Signing keys at ${this.jwksUri} were not valid JSON.`,
|
|
1284
|
+
{ cause }
|
|
1285
|
+
);
|
|
1286
|
+
}
|
|
1287
|
+
const imported = /* @__PURE__ */ new Map();
|
|
1288
|
+
for (const jwk of document.keys ?? []) {
|
|
1289
|
+
if (jwk.kty !== "RSA" || jwk.kid === void 0 || jwk.n === void 0 || jwk.e === void 0) {
|
|
1290
|
+
continue;
|
|
1291
|
+
}
|
|
1292
|
+
if (jwk.alg !== void 0 && jwk.alg !== "RS256") {
|
|
1293
|
+
continue;
|
|
1294
|
+
}
|
|
1295
|
+
try {
|
|
1296
|
+
imported.set(
|
|
1297
|
+
jwk.kid,
|
|
1298
|
+
await crypto.subtle.importKey(
|
|
1299
|
+
"jwk",
|
|
1300
|
+
{ kty: "RSA", n: jwk.n, e: jwk.e, alg: "RS256", ext: true },
|
|
1301
|
+
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
1302
|
+
false,
|
|
1303
|
+
["verify"]
|
|
1304
|
+
)
|
|
1305
|
+
);
|
|
1306
|
+
} catch {
|
|
1307
|
+
continue;
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
if (imported.size === 0) {
|
|
1311
|
+
throw new CanopyTokenError(
|
|
1312
|
+
"token.jwks_unavailable",
|
|
1313
|
+
`Signing keys at ${this.jwksUri} contained no usable RS256 key.`
|
|
1314
|
+
);
|
|
1315
|
+
}
|
|
1316
|
+
this.keys = imported;
|
|
1317
|
+
this.keysFetchedAt = Date.now();
|
|
1318
|
+
}
|
|
1319
|
+
};
|
|
1320
|
+
|
|
1321
|
+
export { Assignments, Canopy, CanopyAuthorizerError, CanopyClient, CanopyConnectionError, CanopyError, CanopyTokenError, Identities, LocalAuthorizer, Paginator, Permissions, Roles, TokenVerifier, isCanopyAuthorizerError, isCanopyConnectionError, isCanopyError, isCanopyTokenError, isCursorPagination, paginate, withConcurrency };
|
|
610
1322
|
//# sourceMappingURL=index.js.map
|
|
611
1323
|
//# sourceMappingURL=index.js.map
|