@cboxdk/id-js 0.14.0 → 0.17.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/dist/index.cjs CHANGED
@@ -43,7 +43,7 @@ async function oauthError(response, fallback) {
43
43
  }
44
44
  } catch {
45
45
  }
46
- const detail = code ?? (body.trim() === "" ? `HTTP ${response.status}` : body.slice(0, 200));
46
+ const detail = code ?? `HTTP ${response.status}`;
47
47
  const retryAfterHeader = response.headers.get("retry-after");
48
48
  const retryAfter = retryAfterHeader !== null && /^\d+$/.test(retryAfterHeader.trim()) ? Number(retryAfterHeader.trim()) : void 0;
49
49
  return new AuthenticationError(`${fallback}: ${detail}`, code, description, response.status, retryAfter);
@@ -153,6 +153,26 @@ function json(body, status) {
153
153
  });
154
154
  }
155
155
 
156
+ // src/issuer.ts
157
+ function assertSecureIssuer(issuer) {
158
+ let url;
159
+ try {
160
+ url = new URL(issuer);
161
+ } catch {
162
+ throw new ConfigurationError("Cbox ID config `issuer` is not a valid URL.");
163
+ }
164
+ if (url.protocol === "https:") {
165
+ return;
166
+ }
167
+ const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1";
168
+ if (url.protocol === "http:" && loopback) {
169
+ return;
170
+ }
171
+ throw new ConfigurationError(
172
+ `Cbox ID config \`issuer\` must be https (got ${url.protocol}//${url.hostname}).`
173
+ );
174
+ }
175
+
156
176
  // src/frontend.ts
157
177
  var CboxIdFrontend = class {
158
178
  issuer;
@@ -173,6 +193,7 @@ var CboxIdFrontend = class {
173
193
  if (!options.issuer) {
174
194
  throw new ConfigurationError("issuer is required.");
175
195
  }
196
+ assertSecureIssuer(options.issuer);
176
197
  if (!options.publishableKey?.startsWith("pk_")) {
177
198
  throw new ConfigurationError(
178
199
  "publishableKey must be a pk_test_\u2026 or pk_live_\u2026 key. Client secrets and API keys must never be put in a browser."
@@ -476,7 +497,7 @@ var Discovery = class {
476
497
  if (this.cached && this.cached.expiresAt > this.now()) {
477
498
  return this.cached.doc;
478
499
  }
479
- const url = `${this.issuer.replace(/\/$/, "")}/.well-known/openid-configuration`;
500
+ const url = `${trimSlash(this.issuer)}/.well-known/openid-configuration`;
480
501
  const controller = new AbortController();
481
502
  const timer = setTimeout(() => controller.abort(), this.timeoutMs);
482
503
  let doc;
@@ -497,6 +518,11 @@ var Discovery = class {
497
518
  if (typeof doc.issuer !== "string" || doc.issuer === "") {
498
519
  throw new AuthenticationError("The discovery document was missing an issuer.");
499
520
  }
521
+ if (trimSlash(doc.issuer) !== trimSlash(this.issuer)) {
522
+ throw new AuthenticationError(
523
+ `The discovery document is for a different issuer (${doc.issuer}).`
524
+ );
525
+ }
500
526
  this.cached = { doc, expiresAt: this.now() + this.cacheTtlMs };
501
527
  return doc;
502
528
  }
@@ -514,6 +540,9 @@ var Discovery = class {
514
540
  return typeof value === "string" && value !== "" ? value : null;
515
541
  }
516
542
  };
543
+ function trimSlash(value) {
544
+ return value.replace(/\/+$/, "");
545
+ }
517
546
 
518
547
  // src/pkce.ts
519
548
  function base64url(bytes) {
@@ -534,6 +563,75 @@ async function challenge(verifier) {
534
563
  return base64url(new Uint8Array(digest));
535
564
  }
536
565
 
566
+ // src/claims.ts
567
+ var ORGANIZATION_ROLES = /* @__PURE__ */ new Set([
568
+ "owner",
569
+ "admin",
570
+ "developer",
571
+ "member",
572
+ "viewer"
573
+ ]);
574
+ function organization(source) {
575
+ const claims = claimsOf(source);
576
+ const id = claims["org"];
577
+ if (typeof id !== "string" || id === "") {
578
+ return null;
579
+ }
580
+ const name = claims["org_name"];
581
+ const role = claims["org_role"];
582
+ return {
583
+ id,
584
+ name: typeof name === "string" && name !== "" ? name : null,
585
+ role: typeof role === "string" && ORGANIZATION_ROLES.has(role) ? role : null
586
+ };
587
+ }
588
+ function actor(source) {
589
+ return parseActor(claimsOf(source)["act"], 0);
590
+ }
591
+ function isSupportSession(source) {
592
+ return actor(source) !== null;
593
+ }
594
+ function sessionId(source) {
595
+ const sid = claimsOf(source)["sid"];
596
+ return typeof sid === "string" && sid !== "" ? sid : null;
597
+ }
598
+ function roles(source) {
599
+ return stringList(claimsOf(source)["roles"]);
600
+ }
601
+ function permissions(source) {
602
+ return stringList(claimsOf(source)["permissions"]);
603
+ }
604
+ function hasRole(source, role) {
605
+ return roles(source).includes(role);
606
+ }
607
+ function hasPermission(source, permission) {
608
+ return permissions(source).includes(permission);
609
+ }
610
+ var MAX_ACTOR_DEPTH = 8;
611
+ function parseActor(claim, depth) {
612
+ if (claim === void 0 || claim === null) {
613
+ return null;
614
+ }
615
+ if (typeof claim !== "object" || Array.isArray(claim)) {
616
+ return { sub: null, actor: null };
617
+ }
618
+ const record = claim;
619
+ const sub = record["sub"];
620
+ return {
621
+ sub: typeof sub === "string" && sub !== "" ? sub : null,
622
+ actor: depth + 1 < MAX_ACTOR_DEPTH ? parseActor(record["act"], depth + 1) : null
623
+ };
624
+ }
625
+ function stringList(claim) {
626
+ return Array.isArray(claim) ? claim.filter((value) => typeof value === "string" && value !== "") : [];
627
+ }
628
+ function claimsOf(source) {
629
+ return isUser(source) ? source.claims : source;
630
+ }
631
+ function isUser(source) {
632
+ return typeof source["claims"] === "object" && source["claims"] !== null && typeof source["accessToken"] === "string";
633
+ }
634
+
537
635
  // src/vault.ts
538
636
  var VaultClient = class {
539
637
  constructor(issuer, token, timeoutMs = 1e4) {
@@ -679,9 +777,7 @@ var CboxIdClient = class {
679
777
  if (!config.clientId) {
680
778
  throw new ConfigurationError("Cbox ID config `clientId` is required.");
681
779
  }
682
- if (!config.redirectUri) {
683
- throw new ConfigurationError("Cbox ID config `redirectUri` is required.");
684
- }
780
+ assertSecureIssuer(config.issuer);
685
781
  this.discovery = new Discovery(
686
782
  config.issuer,
687
783
  config.timeoutMs ?? 1e4,
@@ -697,21 +793,29 @@ var CboxIdClient = class {
697
793
  * to {@link authenticate} on the callback.
698
794
  */
699
795
  async createAuthorizationRequest(options = {}) {
796
+ const prompt = promptValue(options.prompt);
797
+ assertOrganizationOptions(options, prompt);
700
798
  const codeVerifier = createVerifier();
701
799
  const state = options.state ?? randomToken(16);
702
800
  const nonce = randomToken(16);
801
+ const redirectUri = options.redirectUri ?? this.config.redirectUri;
802
+ if (redirectUri === void 0 || redirectUri === "") {
803
+ throw new ConfigurationError(
804
+ "A `redirectUri` is required to start the browser sign-in flow. Set it in the config, or pass it to createAuthorizationRequest."
805
+ );
806
+ }
703
807
  const params = new URLSearchParams({
704
808
  response_type: "code",
705
809
  client_id: this.config.clientId,
706
- redirect_uri: options.redirectUri ?? this.config.redirectUri,
810
+ redirect_uri: redirectUri,
707
811
  scope: (options.scopes ?? this.scopes()).join(" "),
708
812
  state,
709
813
  nonce,
710
814
  code_challenge: await challenge(codeVerifier),
711
815
  code_challenge_method: "S256"
712
816
  });
713
- if (options.prompt) {
714
- params.set("prompt", options.prompt);
817
+ if (prompt.length > 0) {
818
+ params.set("prompt", prompt.join(" "));
715
819
  }
716
820
  if (options.loginHint) {
717
821
  params.set("login_hint", options.loginHint);
@@ -719,6 +823,12 @@ var CboxIdClient = class {
719
823
  if (typeof options.maxAge === "number") {
720
824
  params.set("max_age", String(options.maxAge));
721
825
  }
826
+ if (options.organization !== void 0) {
827
+ params.set("organization", options.organization);
828
+ }
829
+ if (options.organizationHint !== void 0) {
830
+ params.set("organization_hint", options.organizationHint);
831
+ }
722
832
  const endpoint = await this.discovery.endpoint("authorization_endpoint");
723
833
  return {
724
834
  url: `${endpoint}?${params.toString()}`,
@@ -727,9 +837,27 @@ var CboxIdClient = class {
727
837
  nonce,
728
838
  // Carried through so authenticate() can hold the instance to it. Only present
729
839
  // when requested (exactOptionalPropertyTypes).
730
- ...typeof options.maxAge === "number" ? { maxAge: options.maxAge } : {}
840
+ ...typeof options.maxAge === "number" ? { maxAge: options.maxAge } : {},
841
+ ...options.organization !== void 0 ? { organization: options.organization } : {}
731
842
  };
732
843
  }
844
+ /**
845
+ * Switch the signed-in person to another organization: a new authorization bound to
846
+ * `organizationId`. Persist and redirect exactly as for
847
+ * {@link createAuthorizationRequest} — it is one, with `organization` set.
848
+ *
849
+ * Cbox ID already holds the person's session, so this is normally a redirect there and
850
+ * straight back with no sign-in form. The tokens that come back carry the new `org`,
851
+ * `org_role`, `roles` and `permissions`; replace your session with them rather than
852
+ * patching the old one, because every one of those can differ between organizations.
853
+ *
854
+ * A person who is not (or is no longer) an active member of that organization comes
855
+ * back with `error=access_denied`, which {@link authenticate} throws as an
856
+ * `AuthenticationError` with `error === 'access_denied'`.
857
+ */
858
+ switchOrganization(organizationId, options = {}) {
859
+ return this.createAuthorizationRequest({ ...options, organization: organizationId });
860
+ }
733
861
  /**
734
862
  * Complete login on your callback route: verify state, exchange the code with the
735
863
  * PKCE verifier, verify the id_token, and return the authenticated user.
@@ -744,7 +872,9 @@ var CboxIdClient = class {
744
872
  }
745
873
  if (params.error) {
746
874
  throw new AuthenticationError(
747
- `Cbox ID returned an error: ${params.error}${params.error_description ? ` (${params.error_description})` : ""}`
875
+ `Cbox ID returned an error: ${params.error}${params.error_description ? ` (${params.error_description})` : ""}`,
876
+ params.error,
877
+ params.error_description ?? void 0
748
878
  );
749
879
  }
750
880
  if (!params.code) {
@@ -766,6 +896,24 @@ var CboxIdClient = class {
766
896
  "A max_age was requested but no id_token was returned, so the authentication age could not be verified."
767
897
  );
768
898
  }
899
+ const user = await this.identityFrom(tokens, verified);
900
+ if (stored.organization !== void 0 && user.organizationId !== stored.organization) {
901
+ throw new AuthenticationError(
902
+ `The sign-in was bound to organization ${stored.organization}, but the tokens are for ${user.organizationId ?? "no organization"}. The instance may not support organization selection.`
903
+ );
904
+ }
905
+ return user;
906
+ }
907
+ /**
908
+ * Turn a token response into the verified {@link CboxUser} every flow returns.
909
+ *
910
+ * Shared by the browser flow and the device flow rather than written twice: the checks
911
+ * in here — the UserInfo subject matching the id_token's, the merge that lets UserInfo
912
+ * enrich but never overwrite signed claims — are the ones that decide whether an
913
+ * identity is trustworthy. A second copy is a second place for one of them to be
914
+ * quietly missing, and the flow it was missing from would look exactly as correct.
915
+ */
916
+ async identityFrom(tokens, verified) {
769
917
  const profile = await this.userinfo(tokens.access_token);
770
918
  const verifiedSub = verified["sub"];
771
919
  const profileSub = profile["sub"];
@@ -783,6 +931,11 @@ var CboxIdClient = class {
783
931
  email: typeof claims["email"] === "string" ? claims["email"] : null,
784
932
  name: typeof claims["name"] === "string" ? claims["name"] : null,
785
933
  organizationId: typeof claims["org"] === "string" ? claims["org"] : null,
934
+ organization: organization(claims),
935
+ roles: roles(claims),
936
+ permissions: permissions(claims),
937
+ actor: actor(claims),
938
+ sessionId: sessionId(claims),
786
939
  // Only present when the instance emitted the claim (exactOptionalPropertyTypes).
787
940
  ...organizations ? { organizations } : {},
788
941
  claims,
@@ -792,6 +945,118 @@ var CboxIdClient = class {
792
945
  expiresIn: typeof tokens.expires_in === "number" ? tokens.expires_in : 0
793
946
  };
794
947
  }
948
+ /**
949
+ * Start the device authorization grant (RFC 8628) — the flow for a program with no
950
+ * browser of its own: a CLI, a CI job, a container, a TV.
951
+ *
952
+ * Print `userCode` and `verificationUri`, then call
953
+ * {@link CboxIdClient.pollDeviceToken}. If the machine has a desktop you may also open
954
+ * `verificationUriComplete`, which fills the code in — but print the code anyway: the
955
+ * machine running your program is often not the one the person is looking at.
956
+ *
957
+ * The scopes are bounded by what the app is REGISTERED for. A device request naming one
958
+ * outside that ceiling is refused with `invalid_scope` rather than quietly reduced,
959
+ * because no browser is in front of it to notice a smaller grant.
960
+ *
961
+ * @throws ConfigurationError when the instance advertises no device endpoint.
962
+ */
963
+ async requestDeviceAuthorization(scopes) {
964
+ const endpoint = await this.discovery.optionalEndpoint("device_authorization_endpoint");
965
+ if (endpoint === null) {
966
+ throw new ConfigurationError(
967
+ "This instance does not advertise a device_authorization_endpoint, so it does not support CLI sign-in."
968
+ );
969
+ }
970
+ const body = new URLSearchParams({
971
+ client_id: this.config.clientId,
972
+ scope: (scopes ?? this.scopes()).join(" ")
973
+ });
974
+ if (this.config.clientSecret) {
975
+ body.set("client_secret", this.config.clientSecret);
976
+ }
977
+ const response = await this.post(endpoint, body);
978
+ if (!response.ok) {
979
+ throw await oauthError(response, "Device authorization request failed");
980
+ }
981
+ const payload = await response.json();
982
+ const deviceCode = payload["device_code"];
983
+ const userCode = payload["user_code"];
984
+ const verificationUri = payload["verification_uri"];
985
+ if (typeof deviceCode !== "string" || typeof userCode !== "string" || typeof verificationUri !== "string") {
986
+ throw new AuthenticationError("The device authorization response was incomplete.");
987
+ }
988
+ return {
989
+ deviceCode,
990
+ userCode,
991
+ verificationUri,
992
+ verificationUriComplete: typeof payload["verification_uri_complete"] === "string" ? payload["verification_uri_complete"] : null,
993
+ expiresIn: typeof payload["expires_in"] === "number" ? payload["expires_in"] : 600,
994
+ // RFC 8628 §3.2: absent means 5 seconds. Polling faster than the server allows is
995
+ // answered with `slow_down`, so the default is the one the server assumes too.
996
+ interval: typeof payload["interval"] === "number" ? payload["interval"] : 5
997
+ };
998
+ }
999
+ /**
1000
+ * Poll until the person approves, and return them.
1001
+ *
1002
+ * Blocks. It honours the server's `interval`, backs off permanently by five seconds on
1003
+ * `slow_down` (RFC 8628 §3.5), and stops on the three answers that are final: they
1004
+ * declined, the code expired, or tokens arrived. Pass an `AbortSignal` to give up
1005
+ * early — a CLI should let Ctrl-C work.
1006
+ *
1007
+ * @throws AuthenticationError when the person declines or the code expires.
1008
+ */
1009
+ async pollDeviceToken(authorization, options = {}) {
1010
+ const endpoint = await this.discovery.endpoint("token_endpoint");
1011
+ const deadline = Date.now() + authorization.expiresIn * 1e3;
1012
+ let intervalMs = authorization.interval * 1e3;
1013
+ for (; ; ) {
1014
+ if (options.signal?.aborted === true) {
1015
+ throw new AuthenticationError("Device sign-in was cancelled.");
1016
+ }
1017
+ if (Date.now() > deadline) {
1018
+ throw new AuthenticationError(
1019
+ "The device code expired before it was approved. Start the sign-in again."
1020
+ );
1021
+ }
1022
+ await sleep(intervalMs, options.signal);
1023
+ const body = new URLSearchParams({
1024
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
1025
+ device_code: authorization.deviceCode,
1026
+ client_id: this.config.clientId
1027
+ });
1028
+ if (this.config.clientSecret) {
1029
+ body.set("client_secret", this.config.clientSecret);
1030
+ }
1031
+ const response = await this.post(endpoint, body);
1032
+ if (response.ok) {
1033
+ const tokens = await response.json();
1034
+ if (!tokens.access_token) {
1035
+ throw new AuthenticationError("The device token response carried no access token.");
1036
+ }
1037
+ const verified = tokens.id_token ? await this.verifyIdToken(tokens.id_token, "") : {};
1038
+ return await this.identityFrom(tokens, verified);
1039
+ }
1040
+ const payload = await response.json().catch(() => ({}));
1041
+ const error = typeof payload["error"] === "string" ? payload["error"] : "invalid_request";
1042
+ if (error === "authorization_pending") {
1043
+ continue;
1044
+ }
1045
+ if (error === "slow_down") {
1046
+ intervalMs += 5e3;
1047
+ continue;
1048
+ }
1049
+ if (error === "access_denied") {
1050
+ throw new AuthenticationError("Sign-in was declined.");
1051
+ }
1052
+ if (error === "expired_token") {
1053
+ throw new AuthenticationError(
1054
+ "The device code expired before it was approved. Start the sign-in again."
1055
+ );
1056
+ }
1057
+ throw await oauthError(response, "Device sign-in failed");
1058
+ }
1059
+ }
795
1060
  /**
796
1061
  * Exchange a refresh token for a fresh access token (OAuth 2.0 `refresh_token`
797
1062
  * grant). Cbox ID rotates refresh tokens and detects reuse, so ALWAYS persist the
@@ -817,6 +1082,9 @@ var CboxIdClient = class {
817
1082
  if (!tokens.access_token) {
818
1083
  throw new AuthenticationError("The refresh response carried no access token.");
819
1084
  }
1085
+ if (tokens.id_token) {
1086
+ await this.verifyIdToken(tokens.id_token, "");
1087
+ }
820
1088
  return {
821
1089
  accessToken: tokens.access_token,
822
1090
  // Keep the presented token when the server does not rotate (OAuth 2.0 §6);
@@ -836,6 +1104,25 @@ var CboxIdClient = class {
836
1104
  const base = `${this.config.issuer.replace(/\/$/, "")}${this.accountPath()}`;
837
1105
  return returnTo ? `${base}?${new URLSearchParams({ return_to: returnTo }).toString()}` : base;
838
1106
  }
1107
+ /**
1108
+ * The hosted page where a person creates and revokes API keys for your API — Cbox ID's
1109
+ * `/account/api-keys`, preselected to this app (or `clientId`). Keys made there are
1110
+ * checked with `ApiKeyVerifier` from `@cboxdk/id-js/server`.
1111
+ *
1112
+ * `returnTo` becomes a link back to your app, honoured only for an origin the app
1113
+ * registered. `organization` picks which of the person's organizations the keys act in;
1114
+ * omitted, the page uses the one they are in.
1115
+ */
1116
+ apiKeysUrl(options = {}) {
1117
+ const params = new URLSearchParams({ client_id: options.clientId ?? this.config.clientId });
1118
+ if (options.returnTo) {
1119
+ params.set("return_to", options.returnTo);
1120
+ }
1121
+ if (options.organization) {
1122
+ params.set("organization", options.organization);
1123
+ }
1124
+ return `${this.config.issuer.replace(/\/$/, "")}/account/api-keys?${params.toString()}`;
1125
+ }
839
1126
  /**
840
1127
  * The RP-initiated logout URL, or null when the instance advertises none.
841
1128
  *
@@ -975,10 +1262,16 @@ var CboxIdClient = class {
975
1262
  return new VaultClient(this.config.issuer, accessToken, this.config.timeoutMs ?? 1e4);
976
1263
  }
977
1264
  async exchange(code, verifier, redirectUri) {
1265
+ const callback = redirectUri ?? this.config.redirectUri;
1266
+ if (callback === void 0 || callback === "") {
1267
+ throw new ConfigurationError(
1268
+ "A `redirectUri` is required to exchange an authorization code. It must be the same one the authorization request used."
1269
+ );
1270
+ }
978
1271
  const body = new URLSearchParams({
979
1272
  grant_type: "authorization_code",
980
1273
  code,
981
- redirect_uri: redirectUri ?? this.config.redirectUri,
1274
+ redirect_uri: callback,
982
1275
  client_id: this.config.clientId,
983
1276
  code_verifier: verifier
984
1277
  });
@@ -1062,6 +1355,37 @@ var CboxIdClient = class {
1062
1355
  }
1063
1356
  }
1064
1357
  };
1358
+ function promptValue(prompt) {
1359
+ if (prompt === void 0) {
1360
+ return [];
1361
+ }
1362
+ const values = [...new Set(typeof prompt === "string" ? [prompt] : prompt)];
1363
+ if (values.includes("none") && values.length > 1) {
1364
+ throw new ConfigurationError("`prompt: 'none'` cannot be combined with another prompt value.");
1365
+ }
1366
+ return values;
1367
+ }
1368
+ function assertOrganizationOptions(options, prompt) {
1369
+ if (options.organization === "") {
1370
+ throw new ConfigurationError("`organization` is empty. Omit it to sign in without binding to an organization.");
1371
+ }
1372
+ if (options.organizationHint === "") {
1373
+ throw new ConfigurationError("`organizationHint` is empty. Omit it when you have no organization to suggest.");
1374
+ }
1375
+ if (options.organization === void 0) {
1376
+ return;
1377
+ }
1378
+ if (prompt.includes("select_organization")) {
1379
+ throw new ConfigurationError(
1380
+ "`organization` binds the sign-in to one organization, so `prompt: 'select_organization'` has nothing to choose. Use `organizationHint` to preselect an organization in the picker instead."
1381
+ );
1382
+ }
1383
+ if (prompt.includes("create_organization")) {
1384
+ throw new ConfigurationError(
1385
+ "`organization` binds the sign-in to an existing organization and `prompt: 'create_organization'` creates a new one. Send one or the other."
1386
+ );
1387
+ }
1388
+ }
1065
1389
  function timingSafeEqualString(a, b) {
1066
1390
  if (a.length !== b.length) {
1067
1391
  return false;
@@ -1090,25 +1414,44 @@ function parseOrganizations(claim) {
1090
1414
  }
1091
1415
  return orgs;
1092
1416
  }
1417
+ function sleep(ms, signal) {
1418
+ return new Promise((resolve, reject) => {
1419
+ if (signal?.aborted === true) {
1420
+ reject(new AuthenticationError("Device sign-in was cancelled."));
1421
+ return;
1422
+ }
1423
+ const timer = setTimeout(() => {
1424
+ signal?.removeEventListener("abort", onAbort);
1425
+ resolve();
1426
+ }, ms);
1427
+ const onAbort = () => {
1428
+ clearTimeout(timer);
1429
+ reject(new AuthenticationError("Device sign-in was cancelled."));
1430
+ };
1431
+ signal?.addEventListener("abort", onAbort, { once: true });
1432
+ });
1433
+ }
1093
1434
 
1094
1435
  // src/authz.ts
1095
1436
  function defineAuthz(declaration) {
1096
- const permissions = declaration.permissions ?? [];
1097
- const roles = declaration.roles ?? [];
1098
- assertDeclaration(permissions, roles);
1437
+ const permissions2 = declaration.permissions ?? [];
1438
+ const roles2 = declaration.roles ?? [];
1439
+ assertDeclaration(permissions2, roles2);
1099
1440
  if (declaration.legacyLogin) {
1100
1441
  assertLegacyLogin(declaration.legacyLogin);
1101
1442
  }
1102
- return { ...declaration, permissions, roles };
1443
+ return { ...declaration, permissions: permissions2, roles: roles2 };
1103
1444
  }
1104
1445
  async function buildManifest(declaration) {
1105
- const permissions = (declaration.permissions ?? []).map(canonicalPermission);
1106
- const roles = (declaration.roles ?? []).map(canonicalRole);
1107
- assertDeclaration(permissions, roles);
1108
- permissions.sort((a, b) => byteCompare(a.key, b.key));
1109
- roles.sort((a, b) => byteCompare(a.key, b.key));
1110
- const version = (await sha256Hex(canonicalManifestJson(permissions, roles))).slice(0, 16);
1111
- const manifest = { version, permissions, roles };
1446
+ const permissions2 = declaration.permissions ?? [];
1447
+ const roles2 = declaration.roles ?? [];
1448
+ assertDeclaration(permissions2, roles2);
1449
+ const version = (await sha256Hex(canonicalManifestJson(permissions2, roles2))).slice(0, 16);
1450
+ const manifest = {
1451
+ version,
1452
+ permissions: [...permissions2].sort((a, b) => byteCompare(a.key, b.key)).map(wirePermission),
1453
+ roles: [...roles2].sort((a, b) => byteCompare(a.key, b.key)).map(wireRole)
1454
+ };
1112
1455
  if (declaration.legacyLogin) {
1113
1456
  assertLegacyLogin(declaration.legacyLogin);
1114
1457
  manifest.legacy_login = declaration.legacyLogin;
@@ -1165,28 +1508,33 @@ async function mintToken(config, discovery, timeoutMs) {
1165
1508
  }
1166
1509
  return json2.access_token;
1167
1510
  }
1168
- function assertDeclaration(permissions, roles) {
1511
+ var KEY_PATTERN = /^[a-z][a-z0-9_-]*(?:[.:][a-z0-9_-]+)*$/;
1512
+ function assertDeclaration(permissions2, roles2) {
1169
1513
  const permissionKeys = /* @__PURE__ */ new Set();
1170
- for (const permission of permissions) {
1514
+ for (const permission of permissions2) {
1171
1515
  if (!permission.key) {
1172
1516
  throw new ConfigurationError("Every permission needs a non-empty `key`.");
1173
1517
  }
1518
+ assertKey(permission.key, "Permission");
1174
1519
  if (permissionKeys.has(permission.key)) {
1175
1520
  throw new ConfigurationError(`Permission "${permission.key}" is declared more than once.`);
1176
1521
  }
1522
+ assertFlag(permission.tenantAssignable, `Permission "${permission.key}"`);
1177
1523
  permissionKeys.add(permission.key);
1178
1524
  }
1179
1525
  const roleKeys = /* @__PURE__ */ new Set();
1180
- for (const role of roles) {
1526
+ for (const role of roles2) {
1181
1527
  if (!role.key) {
1182
1528
  throw new ConfigurationError("Every role needs a non-empty `key`.");
1183
1529
  }
1530
+ assertKey(role.key, "Role");
1184
1531
  if (!role.name) {
1185
1532
  throw new ConfigurationError(`Role "${role.key}" needs a non-empty \`name\`.`);
1186
1533
  }
1187
1534
  if (roleKeys.has(role.key)) {
1188
1535
  throw new ConfigurationError(`Role "${role.key}" is declared more than once.`);
1189
1536
  }
1537
+ assertFlag(role.tenantAssignable, `Role "${role.key}"`);
1190
1538
  roleKeys.add(role.key);
1191
1539
  for (const reference of role.permissions) {
1192
1540
  if (!permissionKeys.has(reference)) {
@@ -1197,25 +1545,54 @@ function assertDeclaration(permissions, roles) {
1197
1545
  }
1198
1546
  }
1199
1547
  }
1200
- function canonicalPermission(permission) {
1201
- return permission.description === void 0 ? { key: permission.key } : { key: permission.key, description: permission.description };
1548
+ function assertKey(key, what) {
1549
+ if (!KEY_PATTERN.test(key)) {
1550
+ throw new ConfigurationError(
1551
+ `${what} key "${key}" is not a lowercase \`feature:action\` slug (e.g. \`invoices:create\`).`
1552
+ );
1553
+ }
1554
+ }
1555
+ function assertFlag(value, owner) {
1556
+ if (value !== void 0 && typeof value !== "boolean") {
1557
+ throw new ConfigurationError(`${owner} \`tenantAssignable\` must be true or false.`);
1558
+ }
1202
1559
  }
1203
- function canonicalRole(role) {
1204
- const permissions = [...role.permissions].sort(byteCompare);
1205
- return role.description === void 0 ? { key: role.key, name: role.name, permissions } : { key: role.key, name: role.name, description: role.description, permissions };
1560
+ function wirePermission(permission) {
1561
+ return {
1562
+ key: permission.key,
1563
+ ...permission.description === void 0 ? {} : { description: permission.description },
1564
+ ...permission.tenantAssignable === true ? { tenant_assignable: true } : {}
1565
+ };
1206
1566
  }
1207
- function canonicalManifestJson(permissions, roles) {
1567
+ function wireRole(role) {
1568
+ return {
1569
+ key: role.key,
1570
+ name: role.name,
1571
+ ...role.description === void 0 ? {} : { description: role.description },
1572
+ permissions: uniqueSorted(role.permissions),
1573
+ ...role.tenantAssignable === false ? { tenant_assignable: false } : {}
1574
+ };
1575
+ }
1576
+ function canonicalManifestJson(permissions2, roles2) {
1208
1577
  const canonical = {
1209
- permissions: [...permissions].sort((a, b) => byteCompare(a.key, b.key)).map((p) => ({ key: p.key, description: emptyToNull(p.description) })),
1210
- roles: [...roles].sort((a, b) => byteCompare(a.key, b.key)).map((r) => ({
1578
+ permissions: [...permissions2].sort((a, b) => byteCompare(a.key, b.key)).map((p) => ({
1579
+ key: p.key,
1580
+ description: emptyToNull(p.description),
1581
+ ...p.tenantAssignable === true ? { tenant_assignable: true } : {}
1582
+ })),
1583
+ roles: [...roles2].sort((a, b) => byteCompare(a.key, b.key)).map((r) => ({
1211
1584
  key: r.key,
1212
1585
  name: r.name,
1213
1586
  description: emptyToNull(r.description),
1214
- permissions: [...r.permissions].sort(byteCompare)
1587
+ permissions: uniqueSorted(r.permissions),
1588
+ ...r.tenantAssignable === false ? { tenant_assignable: false } : {}
1215
1589
  }))
1216
1590
  };
1217
1591
  return escapeLikePhp(JSON.stringify(canonical));
1218
1592
  }
1593
+ function uniqueSorted(values) {
1594
+ return [...new Set(values)].sort(byteCompare);
1595
+ }
1219
1596
  function emptyToNull(value) {
1220
1597
  return value === void 0 || value === "" ? null : value;
1221
1598
  }
@@ -1267,13 +1644,21 @@ exports.ConfigurationError = ConfigurationError;
1267
1644
  exports.FrontendApiError = FrontendApiError;
1268
1645
  exports.InvalidStateError = InvalidStateError;
1269
1646
  exports.VaultClient = VaultClient;
1647
+ exports.actor = actor;
1270
1648
  exports.buildManifest = buildManifest;
1271
1649
  exports.challenge = challenge;
1272
1650
  exports.createLegacyVerifier = createLegacyVerifier;
1273
1651
  exports.createVerifier = createVerifier;
1274
1652
  exports.defineAuthz = defineAuthz;
1653
+ exports.hasPermission = hasPermission;
1654
+ exports.hasRole = hasRole;
1655
+ exports.isSupportSession = isSupportSession;
1656
+ exports.organization = organization;
1657
+ exports.permissions = permissions;
1275
1658
  exports.publishManifest = publishManifest;
1276
1659
  exports.randomToken = randomToken;
1660
+ exports.roles = roles;
1661
+ exports.sessionId = sessionId;
1277
1662
  exports.verifyWebhook = verifyWebhook;
1278
1663
  //# sourceMappingURL=index.cjs.map
1279
1664
  //# sourceMappingURL=index.cjs.map