@cboxdk/id-js 0.7.0 → 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/dist/index.cjs CHANGED
@@ -2,8 +2,6 @@
2
2
 
3
3
  var jose = require('jose');
4
4
 
5
- // src/client.ts
6
-
7
5
  // src/errors.ts
8
6
  var CboxIdError = class extends Error {
9
7
  constructor(message) {
@@ -17,6 +15,268 @@ var InvalidStateError = class extends CboxIdError {
17
15
  };
18
16
  var AuthenticationError = class extends CboxIdError {
19
17
  };
18
+ var FrontendApiError = class extends CboxIdError {
19
+ constructor(message, code, status, retryAfter) {
20
+ super(message);
21
+ this.code = code;
22
+ this.status = status;
23
+ this.retryAfter = retryAfter;
24
+ }
25
+ code;
26
+ status;
27
+ retryAfter;
28
+ };
29
+
30
+ // src/legacy.ts
31
+ function createLegacyVerifier(options) {
32
+ if (!options.secret || options.secret.length < 32) {
33
+ throw new ConfigurationError(
34
+ "A legacy verifier secret must be at least 32 characters; it is the only thing proving a request came from Cbox ID."
35
+ );
36
+ }
37
+ const tolerance = options.toleranceSeconds ?? 300;
38
+ return async function handle(request) {
39
+ const raw = await request.text();
40
+ if (!await isSignedByCboxId(raw, request.headers.get("X-Cbox-Signature"), options.secret, tolerance)) {
41
+ return json({ error: "unauthorized" }, 401);
42
+ }
43
+ let payload;
44
+ try {
45
+ payload = JSON.parse(raw);
46
+ } catch {
47
+ return json({ error: "invalid_request" }, 400);
48
+ }
49
+ const { email, password } = payload;
50
+ if (typeof email !== "string" || email === "") {
51
+ return json({ error: "invalid_request" }, 400);
52
+ }
53
+ if (typeof password !== "string") {
54
+ return json({ user: null }, 200);
55
+ }
56
+ let user;
57
+ try {
58
+ user = await options.verify(email, password);
59
+ } catch {
60
+ return json({ error: "unavailable" }, 503);
61
+ }
62
+ if (!user) {
63
+ return json({ user: null }, 200);
64
+ }
65
+ return json(
66
+ {
67
+ email: user.email,
68
+ name: user.name ?? null,
69
+ email_verified: user.emailVerified === true,
70
+ ...user.passwordHash ? { password_hash: user.passwordHash } : {}
71
+ },
72
+ 200
73
+ );
74
+ };
75
+ }
76
+ async function isSignedByCboxId(body, header, secret, toleranceSeconds) {
77
+ if (!header) {
78
+ return false;
79
+ }
80
+ const parts = {};
81
+ for (const piece of header.split(",")) {
82
+ const [key2, value] = piece.split("=", 2);
83
+ if (key2 && value) {
84
+ parts[key2.trim()] = value.trim();
85
+ }
86
+ }
87
+ const timestamp = parts["t"] ?? "";
88
+ const signature = parts["v1"] ?? "";
89
+ if (!/^\d+$/.test(timestamp) || signature === "") {
90
+ return false;
91
+ }
92
+ if (Math.abs(Math.floor(Date.now() / 1e3) - Number(timestamp)) > toleranceSeconds) {
93
+ return false;
94
+ }
95
+ const key = await crypto.subtle.importKey(
96
+ "raw",
97
+ new TextEncoder().encode(secret),
98
+ { name: "HMAC", hash: "SHA-256" },
99
+ false,
100
+ ["sign"]
101
+ );
102
+ const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${timestamp}.${body}`));
103
+ const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
104
+ return timingSafeEqual(expected, signature);
105
+ }
106
+ function timingSafeEqual(a, b) {
107
+ if (a.length !== b.length) {
108
+ return false;
109
+ }
110
+ let diff = 0;
111
+ for (let i = 0; i < a.length; i++) {
112
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
113
+ }
114
+ return diff === 0;
115
+ }
116
+ function json(body, status) {
117
+ return new Response(JSON.stringify(body), {
118
+ status,
119
+ headers: { "Content-Type": "application/json" }
120
+ });
121
+ }
122
+
123
+ // src/frontend.ts
124
+ var CboxIdFrontend = class {
125
+ issuer;
126
+ publishableKey;
127
+ timeoutMs;
128
+ retries;
129
+ fetchImpl;
130
+ /**
131
+ * The config document, and the in-flight promise for it.
132
+ *
133
+ * Deduplicated rather than merely cached: a page with a sign-in form, a user button and
134
+ * an organization switcher mounts three components in the same tick, and three parallel
135
+ * fetches of the same immutable document is a waste the SDK should absorb rather than
136
+ * ask every consumer to.
137
+ */
138
+ configPromise = null;
139
+ constructor(options) {
140
+ if (!options.issuer) {
141
+ throw new ConfigurationError("issuer is required.");
142
+ }
143
+ if (!options.publishableKey?.startsWith("pk_")) {
144
+ throw new ConfigurationError(
145
+ "publishableKey must be a pk_test_\u2026 or pk_live_\u2026 key. Client secrets and API keys must never be put in a browser."
146
+ );
147
+ }
148
+ this.issuer = options.issuer.replace(/\/$/, "");
149
+ this.publishableKey = options.publishableKey;
150
+ this.timeoutMs = options.timeoutMs ?? 1e4;
151
+ this.retries = Math.max(0, options.retries ?? 2);
152
+ this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
153
+ }
154
+ /** Whether this key drives real sign-ins. Handy for a "test mode" badge in your own UI. */
155
+ get isLive() {
156
+ return this.publishableKey.startsWith("pk_live_");
157
+ }
158
+ /**
159
+ * The public sign-in configuration.
160
+ *
161
+ * Fetched once per client instance. The document is small and changes when somebody
162
+ * edits it in the console, so a long-lived page picks changes up on its next full load
163
+ * rather than mid-session — which is the right trade for something that decides layout.
164
+ */
165
+ async config() {
166
+ this.configPromise ??= this.get("/frontend/v1/config", {}, isFrontendConfig).catch((error) => {
167
+ this.configPromise = null;
168
+ throw error;
169
+ });
170
+ return this.configPromise;
171
+ }
172
+ /**
173
+ * Who is signed in, given an access token your app already holds.
174
+ *
175
+ * Returns `{ user: null }` rather than throwing when nobody is signed in. That is a
176
+ * state, not an error: a user button renders on pages nobody has signed in on, and
177
+ * making callers treat a rejection as a state is how flash-of-signed-out bugs happen.
178
+ *
179
+ * The publishable key grants nothing here — the token is the entire authority.
180
+ */
181
+ async session(accessToken) {
182
+ if (!accessToken) {
183
+ return { user: null };
184
+ }
185
+ return this.get(
186
+ "/frontend/v1/session",
187
+ { Authorization: `Bearer ${accessToken}` },
188
+ isFrontendSession
189
+ );
190
+ }
191
+ async get(path, extraHeaders = {}, validate) {
192
+ let lastError;
193
+ for (let attempt = 0; attempt <= this.retries; attempt++) {
194
+ if (attempt > 0) {
195
+ await new Promise((resolve) => setTimeout(resolve, 150 * attempt));
196
+ }
197
+ try {
198
+ return await this.attempt(path, extraHeaders, validate);
199
+ } catch (error) {
200
+ lastError = error;
201
+ if (!(error instanceof FrontendApiError) || error.code !== "unavailable") {
202
+ throw error;
203
+ }
204
+ }
205
+ }
206
+ throw lastError;
207
+ }
208
+ async attempt(path, extraHeaders, validate) {
209
+ const controller = new AbortController();
210
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
211
+ let response;
212
+ try {
213
+ response = await this.fetchImpl(`${this.issuer}${path}`, {
214
+ headers: {
215
+ // A header, never a query string: a query string puts the key in server logs,
216
+ // in `Referer` on every outbound link, and in browser history.
217
+ "X-Cbox-Publishable-Key": this.publishableKey,
218
+ Accept: "application/json",
219
+ ...extraHeaders
220
+ },
221
+ signal: controller.signal
222
+ });
223
+ } catch (cause) {
224
+ throw new FrontendApiError(
225
+ `Could not reach Cbox ID at ${this.issuer}. If this is a browser, check that this page's origin is on the key's allow-list \u2014 a refused request fails as a network error by design.`,
226
+ "unavailable"
227
+ );
228
+ } finally {
229
+ clearTimeout(timer);
230
+ }
231
+ if (response.status === 429) {
232
+ const retryAfter = Number(response.headers.get("Retry-After") ?? "");
233
+ throw new FrontendApiError(
234
+ "Cbox ID is rate limiting this key.",
235
+ "rate_limited",
236
+ 429,
237
+ Number.isFinite(retryAfter) ? retryAfter : void 0
238
+ );
239
+ }
240
+ if (response.status === 401 || response.status === 403) {
241
+ throw new FrontendApiError(
242
+ `Cbox ID refused the request (${response.status}). Check that this page's origin is on the key's allow-list, and that the key is not revoked.`,
243
+ "origin_not_allowed",
244
+ response.status
245
+ );
246
+ }
247
+ if (!response.ok) {
248
+ throw new FrontendApiError(
249
+ `Cbox ID returned ${response.status}.`,
250
+ "unavailable",
251
+ response.status
252
+ );
253
+ }
254
+ let body;
255
+ try {
256
+ body = await response.json();
257
+ } catch {
258
+ throw new FrontendApiError("Cbox ID returned a body that is not JSON.", "malformed", response.status);
259
+ }
260
+ if (validate && !validate(body)) {
261
+ throw new FrontendApiError(
262
+ "Cbox ID returned a body that is not the expected document.",
263
+ "malformed",
264
+ response.status
265
+ );
266
+ }
267
+ return body;
268
+ }
269
+ };
270
+ function isFrontendConfig(body) {
271
+ if (typeof body !== "object" || body === null) {
272
+ return false;
273
+ }
274
+ const doc = body;
275
+ return typeof doc.issuer === "string" && typeof doc.endpoints === "object" && doc.endpoints !== null;
276
+ }
277
+ function isFrontendSession(body) {
278
+ return typeof body === "object" && body !== null && "user" in body;
279
+ }
20
280
 
21
281
  // src/discovery.ts
22
282
  var Discovery = class {
@@ -180,7 +440,7 @@ var VaultClient = class {
180
440
  };
181
441
 
182
442
  // src/webhook.ts
183
- function timingSafeEqual(a, b) {
443
+ function timingSafeEqual2(a, b) {
184
444
  if (a.length !== b.length) {
185
445
  return false;
186
446
  }
@@ -225,7 +485,7 @@ async function verifyWebhook(options) {
225
485
  const expected = toHex(
226
486
  await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${timestamp}.${payload}`))
227
487
  );
228
- return timingSafeEqual(expected, signature);
488
+ return timingSafeEqual2(expected, signature);
229
489
  }
230
490
 
231
491
  // src/client.ts
@@ -430,11 +690,11 @@ var CboxIdClient = class {
430
690
  if (!response.ok) {
431
691
  throw new AuthenticationError(`Machine token request failed: ${await response.text()}`);
432
692
  }
433
- const json = await response.json();
434
- if (typeof json.access_token !== "string") {
693
+ const json2 = await response.json();
694
+ if (typeof json2.access_token !== "string") {
435
695
  throw new AuthenticationError("The token response had no access_token.");
436
696
  }
437
- return json.access_token;
697
+ return json2.access_token;
438
698
  }
439
699
  /** The OIDC userinfo claims for an access token. */
440
700
  async userinfo(accessToken) {
@@ -564,7 +824,7 @@ var CboxIdClient = class {
564
824
  }
565
825
  accountPath() {
566
826
  const path = this.config.accountPath;
567
- return path && path !== "" ? `/${path.replace(/^\//, "")}` : "/settings";
827
+ return path && path !== "" ? `/${path.replace(/^\//, "")}` : "/account";
568
828
  }
569
829
  requireSecret() {
570
830
  if (!this.config.clientSecret) {
@@ -623,7 +883,10 @@ function defineAuthz(declaration) {
623
883
  const permissions = declaration.permissions ?? [];
624
884
  const roles = declaration.roles ?? [];
625
885
  assertDeclaration(permissions, roles);
626
- return { permissions, roles };
886
+ if (declaration.legacyLogin) {
887
+ assertLegacyLogin(declaration.legacyLogin);
888
+ }
889
+ return { ...declaration, permissions, roles };
627
890
  }
628
891
  async function buildManifest(declaration) {
629
892
  const permissions = (declaration.permissions ?? []).map(canonicalPermission);
@@ -632,7 +895,12 @@ async function buildManifest(declaration) {
632
895
  permissions.sort((a, b) => byteCompare(a.key, b.key));
633
896
  roles.sort((a, b) => byteCompare(a.key, b.key));
634
897
  const version = (await sha256Hex(canonicalManifestJson(permissions, roles))).slice(0, 16);
635
- return { version, permissions, roles };
898
+ const manifest = { version, permissions, roles };
899
+ if (declaration.legacyLogin) {
900
+ assertLegacyLogin(declaration.legacyLogin);
901
+ manifest.legacy_login = declaration.legacyLogin;
902
+ }
903
+ return manifest;
636
904
  }
637
905
  async function publishManifest(config, declaration) {
638
906
  if (!config.issuer || !config.clientId || !config.clientSecret) {
@@ -658,8 +926,8 @@ async function publishManifest(config, declaration) {
658
926
  if (!response.ok) {
659
927
  throw new AuthenticationError(`Manifest push failed: HTTP ${response.status} ${await response.text()}`);
660
928
  }
661
- const json = await response.json();
662
- return json !== null && typeof json === "object" ? json : {};
929
+ const json2 = await response.json();
930
+ return json2 !== null && typeof json2 === "object" ? json2 : {};
663
931
  }
664
932
  async function mintToken(config, discovery, timeoutMs) {
665
933
  const response = await fetchWithTimeout(
@@ -676,13 +944,13 @@ async function mintToken(config, discovery, timeoutMs) {
676
944
  },
677
945
  timeoutMs
678
946
  );
679
- const json = await response.json().catch(() => ({}));
680
- if (typeof json.access_token !== "string" || json.access_token === "") {
947
+ const json2 = await response.json().catch(() => ({}));
948
+ if (typeof json2.access_token !== "string" || json2.access_token === "") {
681
949
  throw new AuthenticationError(
682
950
  "Could not obtain an apps.manifest access token \u2014 check the client credentials and that the client holds the apps.manifest scope."
683
951
  );
684
952
  }
685
- return json.access_token;
953
+ return json2.access_token;
686
954
  }
687
955
  function assertDeclaration(permissions, roles) {
688
956
  const permissionKeys = /* @__PURE__ */ new Set();
@@ -741,11 +1009,11 @@ function emptyToNull(value) {
741
1009
  function byteCompare(a, b) {
742
1010
  return a < b ? -1 : a > b ? 1 : 0;
743
1011
  }
744
- function escapeLikePhp(json) {
1012
+ function escapeLikePhp(json2) {
745
1013
  let out = "";
746
- for (let i = 0; i < json.length; i++) {
747
- const char = json.charAt(i);
748
- const code = json.charCodeAt(i);
1014
+ for (let i = 0; i < json2.length; i++) {
1015
+ const char = json2.charAt(i);
1016
+ const code = json2.charCodeAt(i);
749
1017
  if (char === "/") {
750
1018
  out += "\\/";
751
1019
  } else if (code > 127) {
@@ -769,15 +1037,26 @@ async function fetchWithTimeout(url, init, timeoutMs) {
769
1037
  clearTimeout(timer);
770
1038
  }
771
1039
  }
1040
+ function assertLegacyLogin(legacy) {
1041
+ if (!legacy.url.startsWith("https://")) {
1042
+ throw new ConfigurationError("legacyLogin.url must be https \u2014 a password must never cross the network in the clear.");
1043
+ }
1044
+ if (!legacy.secret || legacy.secret.length < 32) {
1045
+ throw new ConfigurationError("legacyLogin.secret must be at least 32 characters; it is what proves a request came from Cbox ID.");
1046
+ }
1047
+ }
772
1048
 
773
1049
  exports.AuthenticationError = AuthenticationError;
774
1050
  exports.CboxIdClient = CboxIdClient;
775
1051
  exports.CboxIdError = CboxIdError;
1052
+ exports.CboxIdFrontend = CboxIdFrontend;
776
1053
  exports.ConfigurationError = ConfigurationError;
1054
+ exports.FrontendApiError = FrontendApiError;
777
1055
  exports.InvalidStateError = InvalidStateError;
778
1056
  exports.VaultClient = VaultClient;
779
1057
  exports.buildManifest = buildManifest;
780
1058
  exports.challenge = challenge;
1059
+ exports.createLegacyVerifier = createLegacyVerifier;
781
1060
  exports.createVerifier = createVerifier;
782
1061
  exports.defineAuthz = defineAuthz;
783
1062
  exports.publishManifest = publishManifest;