@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 +16 -0
- package/README.md +56 -14
- package/dist/index.cjs +88 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +99 -22
- package/dist/index.d.ts +99 -22
- package/dist/index.js +87 -2
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
//
|
|
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,
|
|
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
|
|
92
|
-
claims.type; // "identity"
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
103
|
+
return response.status(status).json({ message });
|
|
102
104
|
}
|
|
103
105
|
```
|
|
104
106
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|