@canopy-io/node 0.7.1 → 0.8.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 CHANGED
@@ -1,5 +1,21 @@
1
1
  # @canopy-io/node
2
2
 
3
+ ## 0.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 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`.
8
+
9
+ 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).
10
+
11
+ 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()`.
12
+
13
+ ## 0.7.2
14
+
15
+ ### Patch Changes
16
+
17
+ - 66415c2: The NestJS README's first `CanopyModule.forRoot` example now includes the required `verify` option, and `@canopy-io/node`'s types are regenerated from the published spec, adding the `branding.view` and `branding.manage` API key scopes. No behaviour changes.
18
+
3
19
  ## 0.7.1
4
20
 
5
21
  ### Patch Changes
package/README.md CHANGED
@@ -37,7 +37,8 @@ import { Canopy, isCanopyError } from "@canopy-io/node";
37
37
 
38
38
  const canopy = new Canopy({ apiKey: process.env.CANOPY_API_KEY });
39
39
 
40
- // The call every integrator makes on every request.
40
+ // A fresh decision from the API. On the request path, prefer LocalAuthorizer
41
+ // (below), which answers in-process.
41
42
  const { allowed } = await canopy.permissions.evaluate({
42
43
  identity_id: identityId,
43
44
  permission: "documents.read",
@@ -81,30 +82,43 @@ access tokens RS256 and publishes the keys, so a token is checked locally —
81
82
  one JWKS fetch, then no network on any verification after it.
82
83
 
83
84
  ```ts
84
- import { TokenVerifier, isCanopyTokenError } from "@canopy-io/node";
85
+ import { TokenVerifier, tokenErrorStatus } from "@canopy-io/node";
85
86
 
86
- const verifier = new TokenVerifier();
87
+ const verifier = new TokenVerifier({
88
+ environmentId: process.env.CANOPY_ENVIRONMENT_ID,
89
+ });
87
90
 
88
91
  try {
89
92
  const claims = await verifier.verify(bearerToken);
90
93
 
91
- claims.sub; // the identity or user id
92
- claims.type; // "identity" | "user" | "api_key" | "platform"
94
+ claims.sub; // the identity's id
95
+ claims.type; // "identity", the only type accepted by default
93
96
  claims.environment_id; // present on identity tokens
94
97
  } catch (error) {
95
- if (isCanopyTokenError(error)) {
96
- // Every code here means 401 to the caller who presented the token.
97
- // `error.code` is for your logs: token.expired, token.signature_invalid,
98
- // token.issuer_mismatch, and so on.
99
- }
98
+ // 401 when the token is at fault, 503 when the signing keys could not be
99
+ // read and the token may be good. `error.code` is for your logs:
100
+ // token.expired, token.signature_invalid, token.jwks_unavailable, ...
101
+ const { status, message } = tokenErrorStatus(error);
100
102
 
101
- throw error;
103
+ return response.status(status).json({ message });
102
104
  }
103
105
  ```
104
106
 
105
- Hosted Login tokens carry an audience, so name your client. Direct API tokens
106
- carry none and need no option — and a token that has an `aud` is refused
107
- rather than ignored when you have not said which client you are:
107
+ A verifier accepts only identity tokens, the people who sign in to your
108
+ application. Canopy signs tokens for its own administrators (`user`), its
109
+ staff (`platform`) and some API key flows (`api_key`) with the same keys and
110
+ issuer, so they are refused with `token.type_not_allowed` unless you name them:
111
+ `new TokenVerifier({ ..., allowedTypes: ["identity", "user"] })`.
112
+
113
+ If Canopy cannot be reached when the cached keys are due for a refresh, the
114
+ verifier keeps verifying with the keys it holds; only a verifier that has never
115
+ loaded a key set, or a token signed by a key it has never seen, answers
116
+ `token.jwks_unavailable`.
117
+
118
+ Hosted Login tokens carry an audience, your `client_id`, so name your client.
119
+ Direct API tokens carry none and need no option. A token that has an `aud` is
120
+ refused rather than ignored when you have not said which client you are, so a
121
+ token minted for another client is never accepted by accident:
108
122
 
109
123
  ```ts
110
124
  new TokenVerifier({ audience: process.env.CANOPY_OAUTH_CLIENT_ID });
@@ -241,6 +255,34 @@ every request then finds the cache expired and refetches.
241
255
 
242
256
  `canopy.permissions.evaluate` remains for a decision with no staleness at all.
243
257
 
258
+ `authorizer.invalidate(identityId)` drops one identity's cached grants, so
259
+ the next check reads them fresh. It reaches only this process: with several
260
+ instances behind a load balancer, each keeps its own cache until its TTL runs
261
+ out or it is told to invalidate too.
262
+
263
+ A failed check (Canopy unreachable, a credential that cannot read the
264
+ hierarchy, an identity not in the Environment) throws rather than answering
265
+ `false`. `authorizationErrorStatus` turns the error into the response to send:
266
+ `503` when Canopy did not answer, `500` for a misconfiguration no retry fixes,
267
+ `403` for an identity Canopy does not know. The NestJS guard uses the same
268
+ mapping.
269
+
270
+ ```ts
271
+ import { authorizationErrorStatus } from "@canopy-io/node";
272
+
273
+ try {
274
+ const { allowed } = await authorizer.evaluate(query);
275
+
276
+ if (!allowed) {
277
+ return response.status(403).json({ message: "Forbidden" });
278
+ }
279
+ } catch (error) {
280
+ const { status, message } = authorizationErrorStatus(error);
281
+
282
+ return response.status(status).json({ message });
283
+ }
284
+ ```
285
+
244
286
  The credential needs to be able to read both halves. A `full_access` API key
245
287
  already can; a **scoped** one must list `identity.view` (the grant roots) and
246
288
  `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) {
@@ -1346,6 +1348,56 @@ var LocalAuthorizer = class {
1346
1348
  }
1347
1349
  };
1348
1350
 
1351
+ // src/status.ts
1352
+ function tokenErrorStatus(error) {
1353
+ if (!isCanopyTokenError(error)) {
1354
+ return { status: 500, message: "Authentication could not be checked." };
1355
+ }
1356
+ if (error.code === "token.jwks_unavailable") {
1357
+ return {
1358
+ status: 503,
1359
+ message: "Authentication is temporarily unavailable."
1360
+ };
1361
+ }
1362
+ return { status: 401, message: "Invalid or expired access token." };
1363
+ }
1364
+ function authorizationErrorStatus(error) {
1365
+ if (isCanopyAuthorizerError(error)) {
1366
+ return {
1367
+ status: 500,
1368
+ message: `Authorization is misconfigured: ${error.message}`
1369
+ };
1370
+ }
1371
+ if (!isCanopyError(error)) {
1372
+ return {
1373
+ status: 503,
1374
+ message: "Authorization is temporarily unavailable."
1375
+ };
1376
+ }
1377
+ if (error.isAuthFailure) {
1378
+ return {
1379
+ status: 500,
1380
+ message: "Authorization is misconfigured. Check the Canopy API key and its scopes."
1381
+ };
1382
+ }
1383
+ if (error.statusCode === 404 && error.code === null) {
1384
+ return {
1385
+ status: 500,
1386
+ 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.`
1387
+ };
1388
+ }
1389
+ if (error.statusCode === 404) {
1390
+ return { status: 403, message: "Could not evaluate the permission." };
1391
+ }
1392
+ if (error.isRateLimited || error.statusCode >= 500) {
1393
+ return {
1394
+ status: 503,
1395
+ message: "Authorization is temporarily unavailable."
1396
+ };
1397
+ }
1398
+ return { status: 500, message: "Authorization could not be evaluated." };
1399
+ }
1400
+
1349
1401
  // src/verify.ts
1350
1402
  var HOSTED_ORIGIN = "https://auth.canopy-io.com";
1351
1403
  var DEFAULT_JWKS_CACHE_MAX_AGE_MS = 10 * 60 * 1e3;
@@ -1406,11 +1458,14 @@ var TokenVerifier = class {
1406
1458
  jwksTimeoutMs;
1407
1459
  clockToleranceSec;
1408
1460
  allowPreAuthTokens;
1461
+ allowedTypes;
1409
1462
  fetchImpl;
1410
1463
  /** Imported keys by `kid`, so a repeat verification skips the import cost. */
1411
1464
  keys = /* @__PURE__ */ new Map();
1412
1465
  keysFetchedAt = 0;
1413
1466
  lastFetchAttemptAt = 0;
1467
+ /** Whether the latest read of the key set failed, for an unknown `kid`. */
1468
+ lastFetchFailed = false;
1414
1469
  /** In-flight fetch, so a burst of requests triggers one call, not N. */
1415
1470
  inFlight = null;
1416
1471
  constructor(options = {}) {
@@ -1422,6 +1477,7 @@ var TokenVerifier = class {
1422
1477
  this.jwksTimeoutMs = options.jwksTimeoutMs ?? DEFAULT_JWKS_TIMEOUT_MS;
1423
1478
  this.clockToleranceSec = options.clockToleranceSec ?? DEFAULT_CLOCK_TOLERANCE_SEC;
1424
1479
  this.allowPreAuthTokens = options.allowPreAuthTokens ?? false;
1480
+ this.allowedTypes = new Set(options.allowedTypes ?? ["identity"]);
1425
1481
  const boundFetch = options.fetch ?? globalThis.fetch;
1426
1482
  if (typeof boundFetch !== "function") {
1427
1483
  throw new TypeError(
@@ -1499,6 +1555,14 @@ var TokenVerifier = class {
1499
1555
  `Token has an unrecognised \`type\` claim: ${JSON.stringify(claims.type)}.`
1500
1556
  );
1501
1557
  }
1558
+ if (!this.allowedTypes.has(claims.type)) {
1559
+ throw new CanopyTokenError(
1560
+ "token.type_not_allowed",
1561
+ `Token is for a "${claims.type}" principal; this verifier accepts ${[
1562
+ ...this.allowedTypes
1563
+ ].map((type) => `"${type}"`).join(", ")}. Set \`allowedTypes\` to accept another.`
1564
+ );
1565
+ }
1502
1566
  if (claims.iss !== this.issuer) {
1503
1567
  throw new CanopyTokenError(
1504
1568
  "token.issuer_mismatch",
@@ -1571,7 +1635,13 @@ var TokenVerifier = class {
1571
1635
  const stale = Date.now() - this.keysFetchedAt > this.jwksCacheMaxAgeMs;
1572
1636
  if (this.keys.size === 0 || stale) {
1573
1637
  if (this.shouldRefresh()) {
1574
- await this.refreshKeys();
1638
+ try {
1639
+ await this.refreshKeys();
1640
+ } catch (error) {
1641
+ if (this.keys.size === 0) {
1642
+ throw error;
1643
+ }
1644
+ }
1575
1645
  } else if (this.keys.size === 0) {
1576
1646
  throw new CanopyTokenError(
1577
1647
  "token.jwks_unavailable",
@@ -1590,6 +1660,12 @@ var TokenVerifier = class {
1590
1660
  if (rotated) {
1591
1661
  return rotated;
1592
1662
  }
1663
+ if (this.lastFetchFailed) {
1664
+ throw new CanopyTokenError(
1665
+ "token.jwks_unavailable",
1666
+ `Signing keys at ${this.jwksUri} could not be read to look up kid "${kid}".`
1667
+ );
1668
+ }
1593
1669
  throw new CanopyTokenError(
1594
1670
  "token.key_not_found",
1595
1671
  `No signing key matches kid "${kid}".`
@@ -1620,6 +1696,15 @@ var TokenVerifier = class {
1620
1696
  }
1621
1697
  async fetchKeys() {
1622
1698
  this.lastFetchAttemptAt = Date.now();
1699
+ try {
1700
+ await this.readKeys();
1701
+ this.lastFetchFailed = false;
1702
+ } catch (error) {
1703
+ this.lastFetchFailed = true;
1704
+ throw error;
1705
+ }
1706
+ }
1707
+ async readKeys() {
1623
1708
  let response;
1624
1709
  const controller = new AbortController();
1625
1710
  const timer = this.jwksTimeoutMs > 0 ? setTimeout(() => controller.abort(), this.jwksTimeoutMs) : void 0;
@@ -1707,6 +1792,7 @@ exports.Paginator = Paginator;
1707
1792
  exports.Permissions = Permissions;
1708
1793
  exports.Roles = Roles;
1709
1794
  exports.TokenVerifier = TokenVerifier;
1795
+ exports.authorizationErrorStatus = authorizationErrorStatus;
1710
1796
  exports.isCanopyAuthorizerError = isCanopyAuthorizerError;
1711
1797
  exports.isCanopyConnectionError = isCanopyConnectionError;
1712
1798
  exports.isCanopyError = isCanopyError;
@@ -1715,6 +1801,7 @@ exports.isCursorPagination = isCursorPagination;
1715
1801
  exports.isWebhookEvent = isWebhookEvent;
1716
1802
  exports.orgContext = orgContext;
1717
1803
  exports.paginate = paginate;
1804
+ exports.tokenErrorStatus = tokenErrorStatus;
1718
1805
  exports.withConcurrency = withConcurrency;
1719
1806
  //# sourceMappingURL=index.cjs.map
1720
1807
  //# sourceMappingURL=index.cjs.map