@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/dist/index.js
CHANGED
|
@@ -54,6 +54,8 @@ var CanopyTokenError = class extends Error {
|
|
|
54
54
|
* - `token.audience_mismatch` — `aud` does not include the configured audience
|
|
55
55
|
* - `token.audience_unverified` — token has an `aud` but none was configured
|
|
56
56
|
* - `token.preauth_not_allowed` — a pre-auth token, which grants no access
|
|
57
|
+
* - `token.type_not_allowed` — a principal type the verifier does not accept
|
|
58
|
+
* (see `allowedTypes`)
|
|
57
59
|
*/
|
|
58
60
|
code;
|
|
59
61
|
constructor(code, message, options) {
|
|
@@ -611,11 +613,23 @@ var Identities = class {
|
|
|
611
613
|
* newest first: when and where it was signed in, when it expires, and the
|
|
612
614
|
* organization it is acting in. Read it back after a revoke to confirm,
|
|
613
615
|
* from your own side, that a sign-out took.
|
|
616
|
+
*
|
|
617
|
+
* Paginated (20 per page by default, `take` up to 100), so this returns a
|
|
618
|
+
* `Paginator`: iterate it for every session, or call `.first()` for the
|
|
619
|
+
* newest. The list takes `page` and `take` only; its order is fixed.
|
|
620
|
+
*
|
|
621
|
+
* ```ts
|
|
622
|
+
* for await (const session of canopy.identities.sessions(id)) { … }
|
|
623
|
+
* ```
|
|
614
624
|
*/
|
|
615
|
-
sessions(id) {
|
|
616
|
-
return
|
|
617
|
-
|
|
618
|
-
|
|
625
|
+
sessions(id, query = {}) {
|
|
626
|
+
return paginate(
|
|
627
|
+
(params) => this.client.request(
|
|
628
|
+
"GET",
|
|
629
|
+
`/api/v1/identities/${encodeURIComponent(id)}/sessions`,
|
|
630
|
+
{ query: params }
|
|
631
|
+
),
|
|
632
|
+
{ ...query }
|
|
619
633
|
);
|
|
620
634
|
}
|
|
621
635
|
/**
|
|
@@ -911,11 +925,25 @@ var Organizations = class {
|
|
|
911
925
|
`/api/v1/organizations/${enc(id)}/directory/activity`
|
|
912
926
|
);
|
|
913
927
|
}
|
|
914
|
-
/**
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
928
|
+
/**
|
|
929
|
+
* The groups the provider has pushed, each with its role mapping.
|
|
930
|
+
*
|
|
931
|
+
* Paginated (20 per page by default, `take` up to 100), so this returns a
|
|
932
|
+
* `Paginator`. Sorted by display name unless `sort_by: "created"`; `q`
|
|
933
|
+
* searches display names and external ids.
|
|
934
|
+
*
|
|
935
|
+
* ```ts
|
|
936
|
+
* for await (const group of canopy.organizations.listDirectoryGroups(id)) { … }
|
|
937
|
+
* ```
|
|
938
|
+
*/
|
|
939
|
+
listDirectoryGroups(id, query = {}) {
|
|
940
|
+
return paginate(
|
|
941
|
+
(params) => this.client.request(
|
|
942
|
+
"GET",
|
|
943
|
+
`/api/v1/organizations/${enc(id)}/directory/groups`,
|
|
944
|
+
{ query: params }
|
|
945
|
+
),
|
|
946
|
+
{ ...query }
|
|
919
947
|
);
|
|
920
948
|
}
|
|
921
949
|
/**
|
|
@@ -977,6 +1005,10 @@ var Permissions = class {
|
|
|
977
1005
|
*
|
|
978
1006
|
* Prefer this to a loop over `evaluate` when rendering a screen: the checks
|
|
979
1007
|
* are answered together instead of paying request latency for each.
|
|
1008
|
+
*
|
|
1009
|
+
* One request carries between 1 and 100 checks. More is refused with a
|
|
1010
|
+
* `400` (`CanopyError`, `statusCode` 400) and nothing is evaluated, so split
|
|
1011
|
+
* a larger set into batches of 100.
|
|
980
1012
|
*/
|
|
981
1013
|
evaluateBulk(input, options = {}) {
|
|
982
1014
|
return this.client.request("POST", "/api/v1/permissions/evaluate/bulk", {
|
|
@@ -1118,6 +1150,11 @@ var Canopy = class {
|
|
|
1118
1150
|
var DEFAULT_TTL_MS = 6e4;
|
|
1119
1151
|
var MAX_TTL_MS = 3e5;
|
|
1120
1152
|
var MAX_WALK_DEPTH = 256;
|
|
1153
|
+
var PAGE_SIZE = 100;
|
|
1154
|
+
var MAX_PAGES = 1e4;
|
|
1155
|
+
var MAX_TREE_WALKS = 3;
|
|
1156
|
+
var GRANTS_PATH = (identityId) => `/api/v1/identities/${encodeURIComponent(identityId)}/grants`;
|
|
1157
|
+
var PARENTS_PATH = "/api/v1/nodes/parents";
|
|
1121
1158
|
var LocalAuthorizer = class {
|
|
1122
1159
|
client;
|
|
1123
1160
|
ttlMs;
|
|
@@ -1137,7 +1174,9 @@ var LocalAuthorizer = class {
|
|
|
1137
1174
|
pendingTree;
|
|
1138
1175
|
stats = {
|
|
1139
1176
|
grantFetches: 0,
|
|
1177
|
+
grantPages: 0,
|
|
1140
1178
|
treeRequests: 0,
|
|
1179
|
+
treePages: 0,
|
|
1141
1180
|
treeNotModified: 0
|
|
1142
1181
|
};
|
|
1143
1182
|
constructor(client, options = {}) {
|
|
@@ -1272,16 +1311,36 @@ var LocalAuthorizer = class {
|
|
|
1272
1311
|
this.pendingGrants.set(identityId, read);
|
|
1273
1312
|
return read;
|
|
1274
1313
|
}
|
|
1314
|
+
/**
|
|
1315
|
+
* Every page of the identity's grant roots, folded into permission → nodes.
|
|
1316
|
+
*
|
|
1317
|
+
* Each row is one permission at one node, so a permission granted at three
|
|
1318
|
+
* nodes arrives as three rows, possibly on different pages. Stopping after
|
|
1319
|
+
* the first page would under-report what the identity holds: a false deny,
|
|
1320
|
+
* the safe direction, but a wrong answer all the same.
|
|
1321
|
+
*/
|
|
1275
1322
|
async fetchGrantRoots(identityId) {
|
|
1276
1323
|
this.stats.grantFetches += 1;
|
|
1277
|
-
const
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1324
|
+
const path = GRANTS_PATH(identityId);
|
|
1325
|
+
const rows = paginate(
|
|
1326
|
+
(query) => {
|
|
1327
|
+
this.stats.grantPages += 1;
|
|
1328
|
+
return this.client.request("GET", path, {
|
|
1329
|
+
...this.readOptions,
|
|
1330
|
+
query
|
|
1331
|
+
});
|
|
1332
|
+
},
|
|
1333
|
+
{ page: 1, take: PAGE_SIZE },
|
|
1334
|
+
{ maxPages: MAX_PAGES }
|
|
1281
1335
|
);
|
|
1282
1336
|
const roots = /* @__PURE__ */ new Map();
|
|
1283
|
-
for (const row of
|
|
1284
|
-
roots.
|
|
1337
|
+
for await (const row of rows) {
|
|
1338
|
+
const nodes = roots.get(row.permission);
|
|
1339
|
+
if (nodes) {
|
|
1340
|
+
nodes.add(row.node_id);
|
|
1341
|
+
} else {
|
|
1342
|
+
roots.set(row.permission, /* @__PURE__ */ new Set([row.node_id]));
|
|
1343
|
+
}
|
|
1285
1344
|
}
|
|
1286
1345
|
this.grants.set(identityId, {
|
|
1287
1346
|
roots,
|
|
@@ -1306,43 +1365,154 @@ var LocalAuthorizer = class {
|
|
|
1306
1365
|
* Revalidate rather than re-read. The hierarchy is the expensive half and
|
|
1307
1366
|
* the one that changes least, so the common case is a `304` and no transfer
|
|
1308
1367
|
* at all — the tree stays in memory and only its expiry moves.
|
|
1368
|
+
*
|
|
1369
|
+
* The edges come a page at a time, so only the first page is conditional:
|
|
1370
|
+
* its validator covers the whole hierarchy, and a `304` to it means no page
|
|
1371
|
+
* has changed. On a `200` the remaining pages are read plainly and the map
|
|
1372
|
+
* is rebuilt from every page.
|
|
1373
|
+
*
|
|
1374
|
+
* A walk over several pages is not one snapshot. A node added or removed
|
|
1375
|
+
* between two page reads shifts the rows under the offset, so a row can be
|
|
1376
|
+
* skipped, which would leave a node out of the map until the next refresh.
|
|
1377
|
+
* So the walk is confirmed by asking for the first page again with the
|
|
1378
|
+
* validator it came with: a `304` proves nothing changed while the pages
|
|
1379
|
+
* were read. A `200` is a fresh first page, and the walk starts again from
|
|
1380
|
+
* it, up to {@link MAX_TREE_WALKS} times.
|
|
1309
1381
|
*/
|
|
1310
1382
|
async fetchTree() {
|
|
1311
1383
|
this.stats.treeRequests += 1;
|
|
1312
1384
|
const held = this.tree;
|
|
1313
|
-
|
|
1385
|
+
let first = await this.parentsFirstPage(held?.etag ?? void 0);
|
|
1386
|
+
if (!first.modified) {
|
|
1387
|
+
if (held) {
|
|
1388
|
+
this.stats.treeNotModified += 1;
|
|
1389
|
+
this.tree = { ...held, expiresAt: this.now() + this.ttlMs };
|
|
1390
|
+
return held.parents;
|
|
1391
|
+
}
|
|
1392
|
+
return this.keepTree([], null);
|
|
1393
|
+
}
|
|
1394
|
+
for (let walk = 1; ; walk++) {
|
|
1395
|
+
const edges = await this.parentsRemainingPages(first.data);
|
|
1396
|
+
if (!spansPages(first.data) || first.etag === null) {
|
|
1397
|
+
return this.keepTree(edges, first.etag);
|
|
1398
|
+
}
|
|
1399
|
+
const confirm = await this.parentsFirstPage(first.etag);
|
|
1400
|
+
if (!confirm.modified) {
|
|
1401
|
+
return this.keepTree(edges, first.etag);
|
|
1402
|
+
}
|
|
1403
|
+
if (walk >= MAX_TREE_WALKS) {
|
|
1404
|
+
return this.keepTree(edges, null);
|
|
1405
|
+
}
|
|
1406
|
+
first = confirm;
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
parentsFirstPage(etag) {
|
|
1410
|
+
this.stats.treePages += 1;
|
|
1411
|
+
return this.client.requestConditional(
|
|
1314
1412
|
"GET",
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
this.readOptions
|
|
1413
|
+
PARENTS_PATH,
|
|
1414
|
+
etag,
|
|
1415
|
+
{ ...this.readOptions, query: { page: 1, take: PAGE_SIZE } }
|
|
1318
1416
|
);
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1417
|
+
}
|
|
1418
|
+
/** The first page's edges followed by every later page's. */
|
|
1419
|
+
async parentsRemainingPages(first) {
|
|
1420
|
+
const edges = [...first.items];
|
|
1421
|
+
if (!spansPages(first)) {
|
|
1422
|
+
return edges;
|
|
1323
1423
|
}
|
|
1324
|
-
const
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1424
|
+
const rest = paginate(
|
|
1425
|
+
(query) => {
|
|
1426
|
+
this.stats.treePages += 1;
|
|
1427
|
+
return this.client.request("GET", PARENTS_PATH, {
|
|
1428
|
+
...this.readOptions,
|
|
1429
|
+
query
|
|
1430
|
+
});
|
|
1431
|
+
},
|
|
1432
|
+
{ page: first.pagination.page + 1, take: PAGE_SIZE },
|
|
1433
|
+
{ maxPages: MAX_PAGES - 1 }
|
|
1434
|
+
);
|
|
1435
|
+
for await (const page of rest.pages()) {
|
|
1436
|
+
for (const edge of page.items) {
|
|
1437
|
+
edges.push(edge);
|
|
1336
1438
|
}
|
|
1337
1439
|
}
|
|
1440
|
+
return edges;
|
|
1441
|
+
}
|
|
1442
|
+
keepTree(edges, etag) {
|
|
1443
|
+
const ids = /* @__PURE__ */ new Map();
|
|
1444
|
+
for (const edge of edges) {
|
|
1445
|
+
ids.set(edge.id, edge.id);
|
|
1446
|
+
}
|
|
1447
|
+
const parents = /* @__PURE__ */ new Map();
|
|
1448
|
+
for (const edge of edges) {
|
|
1449
|
+
const parent = edge.parent_node_id ?? null;
|
|
1450
|
+
parents.set(
|
|
1451
|
+
edge.id,
|
|
1452
|
+
parent === null ? null : ids.get(parent) ?? parent
|
|
1453
|
+
);
|
|
1454
|
+
}
|
|
1338
1455
|
this.tree = {
|
|
1339
1456
|
parents,
|
|
1340
|
-
etag
|
|
1457
|
+
etag,
|
|
1341
1458
|
expiresAt: this.now() + this.ttlMs
|
|
1342
1459
|
};
|
|
1343
1460
|
return parents;
|
|
1344
1461
|
}
|
|
1345
1462
|
};
|
|
1463
|
+
function spansPages(first) {
|
|
1464
|
+
return first.pagination.has_next_page && first.items.length > 0;
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
// src/status.ts
|
|
1468
|
+
function tokenErrorStatus(error) {
|
|
1469
|
+
if (!isCanopyTokenError(error)) {
|
|
1470
|
+
return { status: 500, message: "Authentication could not be checked." };
|
|
1471
|
+
}
|
|
1472
|
+
if (error.code === "token.jwks_unavailable") {
|
|
1473
|
+
return {
|
|
1474
|
+
status: 503,
|
|
1475
|
+
message: "Authentication is temporarily unavailable."
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
return { status: 401, message: "Invalid or expired access token." };
|
|
1479
|
+
}
|
|
1480
|
+
function authorizationErrorStatus(error) {
|
|
1481
|
+
if (isCanopyAuthorizerError(error)) {
|
|
1482
|
+
return {
|
|
1483
|
+
status: 500,
|
|
1484
|
+
message: `Authorization is misconfigured: ${error.message}`
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
if (!isCanopyError(error)) {
|
|
1488
|
+
return {
|
|
1489
|
+
status: 503,
|
|
1490
|
+
message: "Authorization is temporarily unavailable."
|
|
1491
|
+
};
|
|
1492
|
+
}
|
|
1493
|
+
if (error.isAuthFailure) {
|
|
1494
|
+
return {
|
|
1495
|
+
status: 500,
|
|
1496
|
+
message: "Authorization is misconfigured. Check the Canopy API key and its scopes."
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
1499
|
+
if (error.statusCode === 404 && error.code === null) {
|
|
1500
|
+
return {
|
|
1501
|
+
status: 500,
|
|
1502
|
+
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.`
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
if (error.statusCode === 404) {
|
|
1506
|
+
return { status: 403, message: "Could not evaluate the permission." };
|
|
1507
|
+
}
|
|
1508
|
+
if (error.isRateLimited || error.statusCode >= 500) {
|
|
1509
|
+
return {
|
|
1510
|
+
status: 503,
|
|
1511
|
+
message: "Authorization is temporarily unavailable."
|
|
1512
|
+
};
|
|
1513
|
+
}
|
|
1514
|
+
return { status: 500, message: "Authorization could not be evaluated." };
|
|
1515
|
+
}
|
|
1346
1516
|
|
|
1347
1517
|
// src/verify.ts
|
|
1348
1518
|
var HOSTED_ORIGIN = "https://auth.canopy-io.com";
|
|
@@ -1404,11 +1574,14 @@ var TokenVerifier = class {
|
|
|
1404
1574
|
jwksTimeoutMs;
|
|
1405
1575
|
clockToleranceSec;
|
|
1406
1576
|
allowPreAuthTokens;
|
|
1577
|
+
allowedTypes;
|
|
1407
1578
|
fetchImpl;
|
|
1408
1579
|
/** Imported keys by `kid`, so a repeat verification skips the import cost. */
|
|
1409
1580
|
keys = /* @__PURE__ */ new Map();
|
|
1410
1581
|
keysFetchedAt = 0;
|
|
1411
1582
|
lastFetchAttemptAt = 0;
|
|
1583
|
+
/** Whether the latest read of the key set failed, for an unknown `kid`. */
|
|
1584
|
+
lastFetchFailed = false;
|
|
1412
1585
|
/** In-flight fetch, so a burst of requests triggers one call, not N. */
|
|
1413
1586
|
inFlight = null;
|
|
1414
1587
|
constructor(options = {}) {
|
|
@@ -1420,6 +1593,7 @@ var TokenVerifier = class {
|
|
|
1420
1593
|
this.jwksTimeoutMs = options.jwksTimeoutMs ?? DEFAULT_JWKS_TIMEOUT_MS;
|
|
1421
1594
|
this.clockToleranceSec = options.clockToleranceSec ?? DEFAULT_CLOCK_TOLERANCE_SEC;
|
|
1422
1595
|
this.allowPreAuthTokens = options.allowPreAuthTokens ?? false;
|
|
1596
|
+
this.allowedTypes = new Set(options.allowedTypes ?? ["identity"]);
|
|
1423
1597
|
const boundFetch = options.fetch ?? globalThis.fetch;
|
|
1424
1598
|
if (typeof boundFetch !== "function") {
|
|
1425
1599
|
throw new TypeError(
|
|
@@ -1497,6 +1671,14 @@ var TokenVerifier = class {
|
|
|
1497
1671
|
`Token has an unrecognised \`type\` claim: ${JSON.stringify(claims.type)}.`
|
|
1498
1672
|
);
|
|
1499
1673
|
}
|
|
1674
|
+
if (!this.allowedTypes.has(claims.type)) {
|
|
1675
|
+
throw new CanopyTokenError(
|
|
1676
|
+
"token.type_not_allowed",
|
|
1677
|
+
`Token is for a "${claims.type}" principal; this verifier accepts ${[
|
|
1678
|
+
...this.allowedTypes
|
|
1679
|
+
].map((type) => `"${type}"`).join(", ")}. Set \`allowedTypes\` to accept another.`
|
|
1680
|
+
);
|
|
1681
|
+
}
|
|
1500
1682
|
if (claims.iss !== this.issuer) {
|
|
1501
1683
|
throw new CanopyTokenError(
|
|
1502
1684
|
"token.issuer_mismatch",
|
|
@@ -1569,7 +1751,13 @@ var TokenVerifier = class {
|
|
|
1569
1751
|
const stale = Date.now() - this.keysFetchedAt > this.jwksCacheMaxAgeMs;
|
|
1570
1752
|
if (this.keys.size === 0 || stale) {
|
|
1571
1753
|
if (this.shouldRefresh()) {
|
|
1572
|
-
|
|
1754
|
+
try {
|
|
1755
|
+
await this.refreshKeys();
|
|
1756
|
+
} catch (error) {
|
|
1757
|
+
if (this.keys.size === 0) {
|
|
1758
|
+
throw error;
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1573
1761
|
} else if (this.keys.size === 0) {
|
|
1574
1762
|
throw new CanopyTokenError(
|
|
1575
1763
|
"token.jwks_unavailable",
|
|
@@ -1588,6 +1776,12 @@ var TokenVerifier = class {
|
|
|
1588
1776
|
if (rotated) {
|
|
1589
1777
|
return rotated;
|
|
1590
1778
|
}
|
|
1779
|
+
if (this.lastFetchFailed) {
|
|
1780
|
+
throw new CanopyTokenError(
|
|
1781
|
+
"token.jwks_unavailable",
|
|
1782
|
+
`Signing keys at ${this.jwksUri} could not be read to look up kid "${kid}".`
|
|
1783
|
+
);
|
|
1784
|
+
}
|
|
1591
1785
|
throw new CanopyTokenError(
|
|
1592
1786
|
"token.key_not_found",
|
|
1593
1787
|
`No signing key matches kid "${kid}".`
|
|
@@ -1618,6 +1812,15 @@ var TokenVerifier = class {
|
|
|
1618
1812
|
}
|
|
1619
1813
|
async fetchKeys() {
|
|
1620
1814
|
this.lastFetchAttemptAt = Date.now();
|
|
1815
|
+
try {
|
|
1816
|
+
await this.readKeys();
|
|
1817
|
+
this.lastFetchFailed = false;
|
|
1818
|
+
} catch (error) {
|
|
1819
|
+
this.lastFetchFailed = true;
|
|
1820
|
+
throw error;
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
async readKeys() {
|
|
1621
1824
|
let response;
|
|
1622
1825
|
const controller = new AbortController();
|
|
1623
1826
|
const timer = this.jwksTimeoutMs > 0 ? setTimeout(() => controller.abort(), this.jwksTimeoutMs) : void 0;
|
|
@@ -1690,6 +1893,6 @@ function isWebhookEvent(delivery, name) {
|
|
|
1690
1893
|
return delivery.event === name;
|
|
1691
1894
|
}
|
|
1692
1895
|
|
|
1693
|
-
export { Assignments, Canopy, CanopyAuthorizerError, CanopyClient, CanopyConnectionError, CanopyError, CanopyTokenError, Identities, LocalAuthorizer, MAX_TTL_MS, Organizations, Paginator, Permissions, Roles, TokenVerifier, isCanopyAuthorizerError, isCanopyConnectionError, isCanopyError, isCanopyTokenError, isCursorPagination, isWebhookEvent, orgContext, paginate, withConcurrency };
|
|
1896
|
+
export { Assignments, Canopy, CanopyAuthorizerError, CanopyClient, CanopyConnectionError, CanopyError, CanopyTokenError, Identities, LocalAuthorizer, MAX_TTL_MS, Organizations, Paginator, Permissions, Roles, TokenVerifier, authorizationErrorStatus, isCanopyAuthorizerError, isCanopyConnectionError, isCanopyError, isCanopyTokenError, isCursorPagination, isWebhookEvent, orgContext, paginate, tokenErrorStatus, withConcurrency };
|
|
1694
1897
|
//# sourceMappingURL=index.js.map
|
|
1695
1898
|
//# sourceMappingURL=index.js.map
|