@canopy-io/node 0.7.2 → 0.9.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/CHANGELOG.md +27 -0
- package/README.md +69 -12
- package/dist/index.cjs +242 -37
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +302 -128
- package/dist/index.d.ts +302 -128
- package/dist/index.js +241 -38
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# @canopy-io/node
|
|
2
2
|
|
|
3
|
+
## 0.9.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 436d639: Follows Canopy's paged hierarchy, grant, session and directory group reads. These endpoints now answer `{ items, pagination }`, at most 100 to a page, where they used to answer the whole collection.
|
|
8
|
+
|
|
9
|
+
`LocalAuthorizer`, and so `CanopyGuard`, reads every page of an identity's grant roots (now one row per permission and node) and every page of the hierarchy's parent edges, 100 at a time. Revalidation still costs one request: only the hierarchy's first page is sent with its `ETag`, and a `304` keeps the held copy. When the hierarchy has changed, every page is read again, and a rebuild over several pages is confirmed against the first page's `ETag` so a change made mid-read is not kept half-applied. `snapshot()` gains `grantPages` and `treePages`, the page requests behind `grantFetches` and `treeRequests`. Earlier versions deny every check against a Canopy that answers grants as one row per node, so upgrade before that API reaches you.
|
|
10
|
+
|
|
11
|
+
Caller shapes that changed:
|
|
12
|
+
|
|
13
|
+
- `identities.sessions(id, query?)` returns a `Paginator` instead of a promise of `{ items }`, and takes `page` and `take`. Use `for await`, `.all()` or `.first()`.
|
|
14
|
+
- `organizations.listDirectoryGroups(id, query?)` returns a `Paginator`, and takes `page`, `take`, `q` (display name or external id) and `sort_by` (`name`, the default, or `created`). The item type is exported as `OrganizationDirectoryGroupItem`.
|
|
15
|
+
- `permissions.evaluateBulk` takes between 1 and 100 checks; more is refused with a `400`.
|
|
16
|
+
- The generated types follow the API: `GET /api/v1/nodes` and `GET /api/v1/nodes/:id/tree` answer pages of flat nodes (`ScopedNodeResponseDto`, linked by `parent_node_id`) instead of a nested tree with its scope, `GET /api/v1/nodes/:id/children` is paged, and `IdentityGrantResponseDto` carries `node_id` instead of `nodes`. Several lists' `sort_by` is now typed to the columns each accepts.
|
|
17
|
+
|
|
18
|
+
`@canopy-io/nestjs` picks up the new authorizer; its own options and behaviour are unchanged.
|
|
19
|
+
|
|
20
|
+
## 0.8.0
|
|
21
|
+
|
|
22
|
+
### Minor Changes
|
|
23
|
+
|
|
24
|
+
- 621b9a6: `TokenVerifier` accepts identity tokens only by default. Tokens Canopy signs for its own administrators (`user`), staff (`platform`) and API key flows (`api_key`) are refused with `token.type_not_allowed` unless named in the new `allowedTypes` option; `CanopyTokenGuard` takes it under `verify.allowedTypes`.
|
|
25
|
+
|
|
26
|
+
When Canopy cannot be reached as cached signing keys come due for a refresh, the verifier keeps verifying with the keys it holds instead of failing every request until the issuer is back. A token signed by a key the verifier has never seen, during an outage, is reported as `token.jwks_unavailable` (503) rather than `token.key_not_found` (401).
|
|
27
|
+
|
|
28
|
+
New exports `tokenErrorStatus` and `authorizationErrorStatus` turn a failed verification or authorization check into the HTTP status and message to answer with; the NestJS guards use them. The node README's verification example now names its Environment, and documents the 503 and the per-process reach of `LocalAuthorizer.invalidate()`.
|
|
29
|
+
|
|
3
30
|
## 0.7.2
|
|
4
31
|
|
|
5
32
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -46,11 +46,14 @@ const { allowed } = await canopy.permissions.evaluate({
|
|
|
46
46
|
node_id: nodeId,
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
-
// Pagination is handled for you, whichever style the endpoint uses.
|
|
49
|
+
// Pagination is handled for you, whichever style the endpoint uses. Every
|
|
50
|
+
// list method returns a Paginator: `for await`, `.all()` or `.first()`.
|
|
50
51
|
for await (const identity of canopy.identities.list({ take: 50 })) {
|
|
51
52
|
console.log(identity.email);
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
const newest = await canopy.identities.sessions(identityId).first();
|
|
56
|
+
|
|
54
57
|
// Errors carry a stable code, not just a message.
|
|
55
58
|
try {
|
|
56
59
|
await canopy.assignments.create({
|
|
@@ -67,6 +70,10 @@ try {
|
|
|
67
70
|
}
|
|
68
71
|
```
|
|
69
72
|
|
|
73
|
+
Lists answer 20 items a page by default and at most 100 (`take`).
|
|
74
|
+
`permissions.evaluateBulk` takes 1 to 100 checks in one request; more is
|
|
75
|
+
refused with a `400` and nothing is evaluated, so send a larger set in batches.
|
|
76
|
+
|
|
70
77
|
Anything without a typed wrapper is reachable the same way, with the same envelope handling and retry policy:
|
|
71
78
|
|
|
72
79
|
```ts
|
|
@@ -82,27 +89,39 @@ access tokens RS256 and publishes the keys, so a token is checked locally —
|
|
|
82
89
|
one JWKS fetch, then no network on any verification after it.
|
|
83
90
|
|
|
84
91
|
```ts
|
|
85
|
-
import { TokenVerifier,
|
|
92
|
+
import { TokenVerifier, tokenErrorStatus } from "@canopy-io/node";
|
|
86
93
|
|
|
87
|
-
const verifier = new TokenVerifier(
|
|
94
|
+
const verifier = new TokenVerifier({
|
|
95
|
+
environmentId: process.env.CANOPY_ENVIRONMENT_ID,
|
|
96
|
+
});
|
|
88
97
|
|
|
89
98
|
try {
|
|
90
99
|
const claims = await verifier.verify(bearerToken);
|
|
91
100
|
|
|
92
|
-
claims.sub; // the identity
|
|
93
|
-
claims.type; // "identity"
|
|
101
|
+
claims.sub; // the identity's id
|
|
102
|
+
claims.type; // "identity", the only type accepted by default
|
|
94
103
|
claims.environment_id; // present on identity tokens
|
|
95
104
|
} catch (error) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
105
|
+
// 401 when the token is at fault, 503 when the signing keys could not be
|
|
106
|
+
// read and the token may be good. `error.code` is for your logs:
|
|
107
|
+
// token.expired, token.signature_invalid, token.jwks_unavailable, ...
|
|
108
|
+
const { status, message } = tokenErrorStatus(error);
|
|
101
109
|
|
|
102
|
-
|
|
110
|
+
return response.status(status).json({ message });
|
|
103
111
|
}
|
|
104
112
|
```
|
|
105
113
|
|
|
114
|
+
A verifier accepts only identity tokens, the people who sign in to your
|
|
115
|
+
application. Canopy signs tokens for its own administrators (`user`), its
|
|
116
|
+
staff (`platform`) and some API key flows (`api_key`) with the same keys and
|
|
117
|
+
issuer, so they are refused with `token.type_not_allowed` unless you name them:
|
|
118
|
+
`new TokenVerifier({ ..., allowedTypes: ["identity", "user"] })`.
|
|
119
|
+
|
|
120
|
+
If Canopy cannot be reached when the cached keys are due for a refresh, the
|
|
121
|
+
verifier keeps verifying with the keys it holds; only a verifier that has never
|
|
122
|
+
loaded a key set, or a token signed by a key it has never seen, answers
|
|
123
|
+
`token.jwks_unavailable`.
|
|
124
|
+
|
|
106
125
|
Hosted Login tokens carry an audience, your `client_id`, so name your client.
|
|
107
126
|
Direct API tokens carry none and need no option. A token that has an `aud` is
|
|
108
127
|
refused rather than ignored when you have not said which client you are, so a
|
|
@@ -231,10 +250,20 @@ const { allowed } = await authorizer.evaluate({
|
|
|
231
250
|
|
|
232
251
|
It holds two things: the identity's grant roots, and one copy of your hierarchy
|
|
233
252
|
— read as parent edges from `GET /api/v1/nodes/parents` rather than the full
|
|
234
|
-
|
|
253
|
+
node list, which is 3.7x smaller — shared across every identity in the process. A check walks up from the node in
|
|
235
254
|
question looking for a grant root — a grant already means _this node and
|
|
236
255
|
everything beneath it_, so the roots are never expanded into their descendants.
|
|
237
256
|
|
|
257
|
+
Both reads are paged, and the authorizer reads every page, 100 rows at a time:
|
|
258
|
+
`GET /api/v1/identities/:id/grants` answers one row per permission and node
|
|
259
|
+
it was granted at, and the hierarchy arrives 100 parent edges to a page. Only
|
|
260
|
+
the hierarchy's first page is revalidated: its `ETag` covers the whole
|
|
261
|
+
hierarchy, so a `304` keeps the copy held without reading any other page. When
|
|
262
|
+
the hierarchy has changed, every page is read again and the copy rebuilt; a
|
|
263
|
+
rebuild over several pages is then confirmed against the first page's `ETag`,
|
|
264
|
+
and read again if the hierarchy changed while its pages were being read. At
|
|
265
|
+
50,000 nodes a rebuild is 501 requests, and a revalidation is one.
|
|
266
|
+
|
|
238
267
|
Both are held for at most 60 seconds, which is the delay between an access
|
|
239
268
|
change and it taking effect, including a moved node. Set `ttlMs` to change it,
|
|
240
269
|
up to five minutes (`300_000`); a longer value throws when the authorizer is
|
|
@@ -243,6 +272,34 @@ every request then finds the cache expired and refetches.
|
|
|
243
272
|
|
|
244
273
|
`canopy.permissions.evaluate` remains for a decision with no staleness at all.
|
|
245
274
|
|
|
275
|
+
`authorizer.invalidate(identityId)` drops one identity's cached grants, so
|
|
276
|
+
the next check reads them fresh. It reaches only this process: with several
|
|
277
|
+
instances behind a load balancer, each keeps its own cache until its TTL runs
|
|
278
|
+
out or it is told to invalidate too.
|
|
279
|
+
|
|
280
|
+
A failed check (Canopy unreachable, a credential that cannot read the
|
|
281
|
+
hierarchy, an identity not in the Environment) throws rather than answering
|
|
282
|
+
`false`. `authorizationErrorStatus` turns the error into the response to send:
|
|
283
|
+
`503` when Canopy did not answer, `500` for a misconfiguration no retry fixes,
|
|
284
|
+
`403` for an identity Canopy does not know. The NestJS guard uses the same
|
|
285
|
+
mapping.
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
import { authorizationErrorStatus } from "@canopy-io/node";
|
|
289
|
+
|
|
290
|
+
try {
|
|
291
|
+
const { allowed } = await authorizer.evaluate(query);
|
|
292
|
+
|
|
293
|
+
if (!allowed) {
|
|
294
|
+
return response.status(403).json({ message: "Forbidden" });
|
|
295
|
+
}
|
|
296
|
+
} catch (error) {
|
|
297
|
+
const { status, message } = authorizationErrorStatus(error);
|
|
298
|
+
|
|
299
|
+
return response.status(status).json({ message });
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
246
303
|
The credential needs to be able to read both halves. A `full_access` API key
|
|
247
304
|
already can; a **scoped** one must list `identity.view` (the grant roots) and
|
|
248
305
|
`hierarchy.view` (the tree). Without the latter the tree endpoint answers an
|
package/dist/index.cjs
CHANGED
|
@@ -56,6 +56,8 @@ var CanopyTokenError = class extends Error {
|
|
|
56
56
|
* - `token.audience_mismatch` — `aud` does not include the configured audience
|
|
57
57
|
* - `token.audience_unverified` — token has an `aud` but none was configured
|
|
58
58
|
* - `token.preauth_not_allowed` — a pre-auth token, which grants no access
|
|
59
|
+
* - `token.type_not_allowed` — a principal type the verifier does not accept
|
|
60
|
+
* (see `allowedTypes`)
|
|
59
61
|
*/
|
|
60
62
|
code;
|
|
61
63
|
constructor(code, message, options) {
|
|
@@ -613,11 +615,23 @@ var Identities = class {
|
|
|
613
615
|
* newest first: when and where it was signed in, when it expires, and the
|
|
614
616
|
* organization it is acting in. Read it back after a revoke to confirm,
|
|
615
617
|
* from your own side, that a sign-out took.
|
|
618
|
+
*
|
|
619
|
+
* Paginated (20 per page by default, `take` up to 100), so this returns a
|
|
620
|
+
* `Paginator`: iterate it for every session, or call `.first()` for the
|
|
621
|
+
* newest. The list takes `page` and `take` only; its order is fixed.
|
|
622
|
+
*
|
|
623
|
+
* ```ts
|
|
624
|
+
* for await (const session of canopy.identities.sessions(id)) { … }
|
|
625
|
+
* ```
|
|
616
626
|
*/
|
|
617
|
-
sessions(id) {
|
|
618
|
-
return
|
|
619
|
-
|
|
620
|
-
|
|
627
|
+
sessions(id, query = {}) {
|
|
628
|
+
return paginate(
|
|
629
|
+
(params) => this.client.request(
|
|
630
|
+
"GET",
|
|
631
|
+
`/api/v1/identities/${encodeURIComponent(id)}/sessions`,
|
|
632
|
+
{ query: params }
|
|
633
|
+
),
|
|
634
|
+
{ ...query }
|
|
621
635
|
);
|
|
622
636
|
}
|
|
623
637
|
/**
|
|
@@ -913,11 +927,25 @@ var Organizations = class {
|
|
|
913
927
|
`/api/v1/organizations/${enc(id)}/directory/activity`
|
|
914
928
|
);
|
|
915
929
|
}
|
|
916
|
-
/**
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
930
|
+
/**
|
|
931
|
+
* The groups the provider has pushed, each with its role mapping.
|
|
932
|
+
*
|
|
933
|
+
* Paginated (20 per page by default, `take` up to 100), so this returns a
|
|
934
|
+
* `Paginator`. Sorted by display name unless `sort_by: "created"`; `q`
|
|
935
|
+
* searches display names and external ids.
|
|
936
|
+
*
|
|
937
|
+
* ```ts
|
|
938
|
+
* for await (const group of canopy.organizations.listDirectoryGroups(id)) { … }
|
|
939
|
+
* ```
|
|
940
|
+
*/
|
|
941
|
+
listDirectoryGroups(id, query = {}) {
|
|
942
|
+
return paginate(
|
|
943
|
+
(params) => this.client.request(
|
|
944
|
+
"GET",
|
|
945
|
+
`/api/v1/organizations/${enc(id)}/directory/groups`,
|
|
946
|
+
{ query: params }
|
|
947
|
+
),
|
|
948
|
+
{ ...query }
|
|
921
949
|
);
|
|
922
950
|
}
|
|
923
951
|
/**
|
|
@@ -979,6 +1007,10 @@ var Permissions = class {
|
|
|
979
1007
|
*
|
|
980
1008
|
* Prefer this to a loop over `evaluate` when rendering a screen: the checks
|
|
981
1009
|
* are answered together instead of paying request latency for each.
|
|
1010
|
+
*
|
|
1011
|
+
* One request carries between 1 and 100 checks. More is refused with a
|
|
1012
|
+
* `400` (`CanopyError`, `statusCode` 400) and nothing is evaluated, so split
|
|
1013
|
+
* a larger set into batches of 100.
|
|
982
1014
|
*/
|
|
983
1015
|
evaluateBulk(input, options = {}) {
|
|
984
1016
|
return this.client.request("POST", "/api/v1/permissions/evaluate/bulk", {
|
|
@@ -1120,6 +1152,11 @@ var Canopy = class {
|
|
|
1120
1152
|
var DEFAULT_TTL_MS = 6e4;
|
|
1121
1153
|
var MAX_TTL_MS = 3e5;
|
|
1122
1154
|
var MAX_WALK_DEPTH = 256;
|
|
1155
|
+
var PAGE_SIZE = 100;
|
|
1156
|
+
var MAX_PAGES = 1e4;
|
|
1157
|
+
var MAX_TREE_WALKS = 3;
|
|
1158
|
+
var GRANTS_PATH = (identityId) => `/api/v1/identities/${encodeURIComponent(identityId)}/grants`;
|
|
1159
|
+
var PARENTS_PATH = "/api/v1/nodes/parents";
|
|
1123
1160
|
var LocalAuthorizer = class {
|
|
1124
1161
|
client;
|
|
1125
1162
|
ttlMs;
|
|
@@ -1139,7 +1176,9 @@ var LocalAuthorizer = class {
|
|
|
1139
1176
|
pendingTree;
|
|
1140
1177
|
stats = {
|
|
1141
1178
|
grantFetches: 0,
|
|
1179
|
+
grantPages: 0,
|
|
1142
1180
|
treeRequests: 0,
|
|
1181
|
+
treePages: 0,
|
|
1143
1182
|
treeNotModified: 0
|
|
1144
1183
|
};
|
|
1145
1184
|
constructor(client, options = {}) {
|
|
@@ -1274,16 +1313,36 @@ var LocalAuthorizer = class {
|
|
|
1274
1313
|
this.pendingGrants.set(identityId, read);
|
|
1275
1314
|
return read;
|
|
1276
1315
|
}
|
|
1316
|
+
/**
|
|
1317
|
+
* Every page of the identity's grant roots, folded into permission → nodes.
|
|
1318
|
+
*
|
|
1319
|
+
* Each row is one permission at one node, so a permission granted at three
|
|
1320
|
+
* nodes arrives as three rows, possibly on different pages. Stopping after
|
|
1321
|
+
* the first page would under-report what the identity holds: a false deny,
|
|
1322
|
+
* the safe direction, but a wrong answer all the same.
|
|
1323
|
+
*/
|
|
1277
1324
|
async fetchGrantRoots(identityId) {
|
|
1278
1325
|
this.stats.grantFetches += 1;
|
|
1279
|
-
const
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1326
|
+
const path = GRANTS_PATH(identityId);
|
|
1327
|
+
const rows = paginate(
|
|
1328
|
+
(query) => {
|
|
1329
|
+
this.stats.grantPages += 1;
|
|
1330
|
+
return this.client.request("GET", path, {
|
|
1331
|
+
...this.readOptions,
|
|
1332
|
+
query
|
|
1333
|
+
});
|
|
1334
|
+
},
|
|
1335
|
+
{ page: 1, take: PAGE_SIZE },
|
|
1336
|
+
{ maxPages: MAX_PAGES }
|
|
1283
1337
|
);
|
|
1284
1338
|
const roots = /* @__PURE__ */ new Map();
|
|
1285
|
-
for (const row of
|
|
1286
|
-
roots.
|
|
1339
|
+
for await (const row of rows) {
|
|
1340
|
+
const nodes = roots.get(row.permission);
|
|
1341
|
+
if (nodes) {
|
|
1342
|
+
nodes.add(row.node_id);
|
|
1343
|
+
} else {
|
|
1344
|
+
roots.set(row.permission, /* @__PURE__ */ new Set([row.node_id]));
|
|
1345
|
+
}
|
|
1287
1346
|
}
|
|
1288
1347
|
this.grants.set(identityId, {
|
|
1289
1348
|
roots,
|
|
@@ -1308,43 +1367,154 @@ var LocalAuthorizer = class {
|
|
|
1308
1367
|
* Revalidate rather than re-read. The hierarchy is the expensive half and
|
|
1309
1368
|
* the one that changes least, so the common case is a `304` and no transfer
|
|
1310
1369
|
* at all — the tree stays in memory and only its expiry moves.
|
|
1370
|
+
*
|
|
1371
|
+
* The edges come a page at a time, so only the first page is conditional:
|
|
1372
|
+
* its validator covers the whole hierarchy, and a `304` to it means no page
|
|
1373
|
+
* has changed. On a `200` the remaining pages are read plainly and the map
|
|
1374
|
+
* is rebuilt from every page.
|
|
1375
|
+
*
|
|
1376
|
+
* A walk over several pages is not one snapshot. A node added or removed
|
|
1377
|
+
* between two page reads shifts the rows under the offset, so a row can be
|
|
1378
|
+
* skipped, which would leave a node out of the map until the next refresh.
|
|
1379
|
+
* So the walk is confirmed by asking for the first page again with the
|
|
1380
|
+
* validator it came with: a `304` proves nothing changed while the pages
|
|
1381
|
+
* were read. A `200` is a fresh first page, and the walk starts again from
|
|
1382
|
+
* it, up to {@link MAX_TREE_WALKS} times.
|
|
1311
1383
|
*/
|
|
1312
1384
|
async fetchTree() {
|
|
1313
1385
|
this.stats.treeRequests += 1;
|
|
1314
1386
|
const held = this.tree;
|
|
1315
|
-
|
|
1387
|
+
let first = await this.parentsFirstPage(held?.etag ?? void 0);
|
|
1388
|
+
if (!first.modified) {
|
|
1389
|
+
if (held) {
|
|
1390
|
+
this.stats.treeNotModified += 1;
|
|
1391
|
+
this.tree = { ...held, expiresAt: this.now() + this.ttlMs };
|
|
1392
|
+
return held.parents;
|
|
1393
|
+
}
|
|
1394
|
+
return this.keepTree([], null);
|
|
1395
|
+
}
|
|
1396
|
+
for (let walk = 1; ; walk++) {
|
|
1397
|
+
const edges = await this.parentsRemainingPages(first.data);
|
|
1398
|
+
if (!spansPages(first.data) || first.etag === null) {
|
|
1399
|
+
return this.keepTree(edges, first.etag);
|
|
1400
|
+
}
|
|
1401
|
+
const confirm = await this.parentsFirstPage(first.etag);
|
|
1402
|
+
if (!confirm.modified) {
|
|
1403
|
+
return this.keepTree(edges, first.etag);
|
|
1404
|
+
}
|
|
1405
|
+
if (walk >= MAX_TREE_WALKS) {
|
|
1406
|
+
return this.keepTree(edges, null);
|
|
1407
|
+
}
|
|
1408
|
+
first = confirm;
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
parentsFirstPage(etag) {
|
|
1412
|
+
this.stats.treePages += 1;
|
|
1413
|
+
return this.client.requestConditional(
|
|
1316
1414
|
"GET",
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
this.readOptions
|
|
1415
|
+
PARENTS_PATH,
|
|
1416
|
+
etag,
|
|
1417
|
+
{ ...this.readOptions, query: { page: 1, take: PAGE_SIZE } }
|
|
1320
1418
|
);
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1419
|
+
}
|
|
1420
|
+
/** The first page's edges followed by every later page's. */
|
|
1421
|
+
async parentsRemainingPages(first) {
|
|
1422
|
+
const edges = [...first.items];
|
|
1423
|
+
if (!spansPages(first)) {
|
|
1424
|
+
return edges;
|
|
1325
1425
|
}
|
|
1326
|
-
const
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1426
|
+
const rest = paginate(
|
|
1427
|
+
(query) => {
|
|
1428
|
+
this.stats.treePages += 1;
|
|
1429
|
+
return this.client.request("GET", PARENTS_PATH, {
|
|
1430
|
+
...this.readOptions,
|
|
1431
|
+
query
|
|
1432
|
+
});
|
|
1433
|
+
},
|
|
1434
|
+
{ page: first.pagination.page + 1, take: PAGE_SIZE },
|
|
1435
|
+
{ maxPages: MAX_PAGES - 1 }
|
|
1436
|
+
);
|
|
1437
|
+
for await (const page of rest.pages()) {
|
|
1438
|
+
for (const edge of page.items) {
|
|
1439
|
+
edges.push(edge);
|
|
1338
1440
|
}
|
|
1339
1441
|
}
|
|
1442
|
+
return edges;
|
|
1443
|
+
}
|
|
1444
|
+
keepTree(edges, etag) {
|
|
1445
|
+
const ids = /* @__PURE__ */ new Map();
|
|
1446
|
+
for (const edge of edges) {
|
|
1447
|
+
ids.set(edge.id, edge.id);
|
|
1448
|
+
}
|
|
1449
|
+
const parents = /* @__PURE__ */ new Map();
|
|
1450
|
+
for (const edge of edges) {
|
|
1451
|
+
const parent = edge.parent_node_id ?? null;
|
|
1452
|
+
parents.set(
|
|
1453
|
+
edge.id,
|
|
1454
|
+
parent === null ? null : ids.get(parent) ?? parent
|
|
1455
|
+
);
|
|
1456
|
+
}
|
|
1340
1457
|
this.tree = {
|
|
1341
1458
|
parents,
|
|
1342
|
-
etag
|
|
1459
|
+
etag,
|
|
1343
1460
|
expiresAt: this.now() + this.ttlMs
|
|
1344
1461
|
};
|
|
1345
1462
|
return parents;
|
|
1346
1463
|
}
|
|
1347
1464
|
};
|
|
1465
|
+
function spansPages(first) {
|
|
1466
|
+
return first.pagination.has_next_page && first.items.length > 0;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
// src/status.ts
|
|
1470
|
+
function tokenErrorStatus(error) {
|
|
1471
|
+
if (!isCanopyTokenError(error)) {
|
|
1472
|
+
return { status: 500, message: "Authentication could not be checked." };
|
|
1473
|
+
}
|
|
1474
|
+
if (error.code === "token.jwks_unavailable") {
|
|
1475
|
+
return {
|
|
1476
|
+
status: 503,
|
|
1477
|
+
message: "Authentication is temporarily unavailable."
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
return { status: 401, message: "Invalid or expired access token." };
|
|
1481
|
+
}
|
|
1482
|
+
function authorizationErrorStatus(error) {
|
|
1483
|
+
if (isCanopyAuthorizerError(error)) {
|
|
1484
|
+
return {
|
|
1485
|
+
status: 500,
|
|
1486
|
+
message: `Authorization is misconfigured: ${error.message}`
|
|
1487
|
+
};
|
|
1488
|
+
}
|
|
1489
|
+
if (!isCanopyError(error)) {
|
|
1490
|
+
return {
|
|
1491
|
+
status: 503,
|
|
1492
|
+
message: "Authorization is temporarily unavailable."
|
|
1493
|
+
};
|
|
1494
|
+
}
|
|
1495
|
+
if (error.isAuthFailure) {
|
|
1496
|
+
return {
|
|
1497
|
+
status: 500,
|
|
1498
|
+
message: "Authorization is misconfigured. Check the Canopy API key and its scopes."
|
|
1499
|
+
};
|
|
1500
|
+
}
|
|
1501
|
+
if (error.statusCode === 404 && error.code === null) {
|
|
1502
|
+
return {
|
|
1503
|
+
status: 500,
|
|
1504
|
+
message: `Authorization is misconfigured: ${error.request.method} ${error.request.path} is not served by this Canopy. The SDK requires an API that exposes the identity grants endpoint.`
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1507
|
+
if (error.statusCode === 404) {
|
|
1508
|
+
return { status: 403, message: "Could not evaluate the permission." };
|
|
1509
|
+
}
|
|
1510
|
+
if (error.isRateLimited || error.statusCode >= 500) {
|
|
1511
|
+
return {
|
|
1512
|
+
status: 503,
|
|
1513
|
+
message: "Authorization is temporarily unavailable."
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
return { status: 500, message: "Authorization could not be evaluated." };
|
|
1517
|
+
}
|
|
1348
1518
|
|
|
1349
1519
|
// src/verify.ts
|
|
1350
1520
|
var HOSTED_ORIGIN = "https://auth.canopy-io.com";
|
|
@@ -1406,11 +1576,14 @@ var TokenVerifier = class {
|
|
|
1406
1576
|
jwksTimeoutMs;
|
|
1407
1577
|
clockToleranceSec;
|
|
1408
1578
|
allowPreAuthTokens;
|
|
1579
|
+
allowedTypes;
|
|
1409
1580
|
fetchImpl;
|
|
1410
1581
|
/** Imported keys by `kid`, so a repeat verification skips the import cost. */
|
|
1411
1582
|
keys = /* @__PURE__ */ new Map();
|
|
1412
1583
|
keysFetchedAt = 0;
|
|
1413
1584
|
lastFetchAttemptAt = 0;
|
|
1585
|
+
/** Whether the latest read of the key set failed, for an unknown `kid`. */
|
|
1586
|
+
lastFetchFailed = false;
|
|
1414
1587
|
/** In-flight fetch, so a burst of requests triggers one call, not N. */
|
|
1415
1588
|
inFlight = null;
|
|
1416
1589
|
constructor(options = {}) {
|
|
@@ -1422,6 +1595,7 @@ var TokenVerifier = class {
|
|
|
1422
1595
|
this.jwksTimeoutMs = options.jwksTimeoutMs ?? DEFAULT_JWKS_TIMEOUT_MS;
|
|
1423
1596
|
this.clockToleranceSec = options.clockToleranceSec ?? DEFAULT_CLOCK_TOLERANCE_SEC;
|
|
1424
1597
|
this.allowPreAuthTokens = options.allowPreAuthTokens ?? false;
|
|
1598
|
+
this.allowedTypes = new Set(options.allowedTypes ?? ["identity"]);
|
|
1425
1599
|
const boundFetch = options.fetch ?? globalThis.fetch;
|
|
1426
1600
|
if (typeof boundFetch !== "function") {
|
|
1427
1601
|
throw new TypeError(
|
|
@@ -1499,6 +1673,14 @@ var TokenVerifier = class {
|
|
|
1499
1673
|
`Token has an unrecognised \`type\` claim: ${JSON.stringify(claims.type)}.`
|
|
1500
1674
|
);
|
|
1501
1675
|
}
|
|
1676
|
+
if (!this.allowedTypes.has(claims.type)) {
|
|
1677
|
+
throw new CanopyTokenError(
|
|
1678
|
+
"token.type_not_allowed",
|
|
1679
|
+
`Token is for a "${claims.type}" principal; this verifier accepts ${[
|
|
1680
|
+
...this.allowedTypes
|
|
1681
|
+
].map((type) => `"${type}"`).join(", ")}. Set \`allowedTypes\` to accept another.`
|
|
1682
|
+
);
|
|
1683
|
+
}
|
|
1502
1684
|
if (claims.iss !== this.issuer) {
|
|
1503
1685
|
throw new CanopyTokenError(
|
|
1504
1686
|
"token.issuer_mismatch",
|
|
@@ -1571,7 +1753,13 @@ var TokenVerifier = class {
|
|
|
1571
1753
|
const stale = Date.now() - this.keysFetchedAt > this.jwksCacheMaxAgeMs;
|
|
1572
1754
|
if (this.keys.size === 0 || stale) {
|
|
1573
1755
|
if (this.shouldRefresh()) {
|
|
1574
|
-
|
|
1756
|
+
try {
|
|
1757
|
+
await this.refreshKeys();
|
|
1758
|
+
} catch (error) {
|
|
1759
|
+
if (this.keys.size === 0) {
|
|
1760
|
+
throw error;
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1575
1763
|
} else if (this.keys.size === 0) {
|
|
1576
1764
|
throw new CanopyTokenError(
|
|
1577
1765
|
"token.jwks_unavailable",
|
|
@@ -1590,6 +1778,12 @@ var TokenVerifier = class {
|
|
|
1590
1778
|
if (rotated) {
|
|
1591
1779
|
return rotated;
|
|
1592
1780
|
}
|
|
1781
|
+
if (this.lastFetchFailed) {
|
|
1782
|
+
throw new CanopyTokenError(
|
|
1783
|
+
"token.jwks_unavailable",
|
|
1784
|
+
`Signing keys at ${this.jwksUri} could not be read to look up kid "${kid}".`
|
|
1785
|
+
);
|
|
1786
|
+
}
|
|
1593
1787
|
throw new CanopyTokenError(
|
|
1594
1788
|
"token.key_not_found",
|
|
1595
1789
|
`No signing key matches kid "${kid}".`
|
|
@@ -1620,6 +1814,15 @@ var TokenVerifier = class {
|
|
|
1620
1814
|
}
|
|
1621
1815
|
async fetchKeys() {
|
|
1622
1816
|
this.lastFetchAttemptAt = Date.now();
|
|
1817
|
+
try {
|
|
1818
|
+
await this.readKeys();
|
|
1819
|
+
this.lastFetchFailed = false;
|
|
1820
|
+
} catch (error) {
|
|
1821
|
+
this.lastFetchFailed = true;
|
|
1822
|
+
throw error;
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
async readKeys() {
|
|
1623
1826
|
let response;
|
|
1624
1827
|
const controller = new AbortController();
|
|
1625
1828
|
const timer = this.jwksTimeoutMs > 0 ? setTimeout(() => controller.abort(), this.jwksTimeoutMs) : void 0;
|
|
@@ -1707,6 +1910,7 @@ exports.Paginator = Paginator;
|
|
|
1707
1910
|
exports.Permissions = Permissions;
|
|
1708
1911
|
exports.Roles = Roles;
|
|
1709
1912
|
exports.TokenVerifier = TokenVerifier;
|
|
1913
|
+
exports.authorizationErrorStatus = authorizationErrorStatus;
|
|
1710
1914
|
exports.isCanopyAuthorizerError = isCanopyAuthorizerError;
|
|
1711
1915
|
exports.isCanopyConnectionError = isCanopyConnectionError;
|
|
1712
1916
|
exports.isCanopyError = isCanopyError;
|
|
@@ -1715,6 +1919,7 @@ exports.isCursorPagination = isCursorPagination;
|
|
|
1715
1919
|
exports.isWebhookEvent = isWebhookEvent;
|
|
1716
1920
|
exports.orgContext = orgContext;
|
|
1717
1921
|
exports.paginate = paginate;
|
|
1922
|
+
exports.tokenErrorStatus = tokenErrorStatus;
|
|
1718
1923
|
exports.withConcurrency = withConcurrency;
|
|
1719
1924
|
//# sourceMappingURL=index.cjs.map
|
|
1720
1925
|
//# sourceMappingURL=index.cjs.map
|