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