@cboxdk/id-js 0.15.1 → 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.
@@ -1,63 +1,6 @@
1
+ import { AuthenticationError, ConfigurationError, assertSecureIssuer, InvalidStateError, oauthError } from './chunk-ASOQN47M.js';
1
2
  import { createRemoteJWKSet, jwtVerify } from 'jose';
2
3
 
3
- // src/errors.ts
4
- var CboxIdError = class extends Error {
5
- constructor(message) {
6
- super(message);
7
- this.name = new.target.name;
8
- }
9
- };
10
- var ConfigurationError = class extends CboxIdError {
11
- };
12
- var InvalidStateError = class extends CboxIdError {
13
- };
14
- var AuthenticationError = class extends CboxIdError {
15
- constructor(message, error, errorDescription, status, retryAfter) {
16
- super(message);
17
- this.error = error;
18
- this.errorDescription = errorDescription;
19
- this.status = status;
20
- this.retryAfter = retryAfter;
21
- }
22
- error;
23
- errorDescription;
24
- status;
25
- retryAfter;
26
- /** Whether waiting `retryAfter` seconds and repeating the same request is worth it. */
27
- get isRateLimited() {
28
- return this.status === 429;
29
- }
30
- };
31
- async function oauthError(response, fallback) {
32
- const body = await response.text().catch(() => "");
33
- let code;
34
- let description;
35
- try {
36
- const parsed = JSON.parse(body);
37
- if (typeof parsed === "object" && parsed !== null) {
38
- const { error, error_description: errorDescription } = parsed;
39
- code = typeof error === "string" ? error : void 0;
40
- description = typeof errorDescription === "string" ? errorDescription : void 0;
41
- }
42
- } catch {
43
- }
44
- const detail = code ?? `HTTP ${response.status}`;
45
- const retryAfterHeader = response.headers.get("retry-after");
46
- const retryAfter = retryAfterHeader !== null && /^\d+$/.test(retryAfterHeader.trim()) ? Number(retryAfterHeader.trim()) : void 0;
47
- return new AuthenticationError(`${fallback}: ${detail}`, code, description, response.status, retryAfter);
48
- }
49
- var FrontendApiError = class extends CboxIdError {
50
- constructor(message, code, status, retryAfter) {
51
- super(message);
52
- this.code = code;
53
- this.status = status;
54
- this.retryAfter = retryAfter;
55
- }
56
- code;
57
- status;
58
- retryAfter;
59
- };
60
-
61
4
  // src/pkce.ts
62
5
  function base64url(bytes) {
63
6
  let binary = "";
@@ -77,6 +20,75 @@ async function challenge(verifier) {
77
20
  return base64url(new Uint8Array(digest));
78
21
  }
79
22
 
23
+ // src/claims.ts
24
+ var ORGANIZATION_ROLES = /* @__PURE__ */ new Set([
25
+ "owner",
26
+ "admin",
27
+ "developer",
28
+ "member",
29
+ "viewer"
30
+ ]);
31
+ function organization(source) {
32
+ const claims = claimsOf(source);
33
+ const id = claims["org"];
34
+ if (typeof id !== "string" || id === "") {
35
+ return null;
36
+ }
37
+ const name = claims["org_name"];
38
+ const role = claims["org_role"];
39
+ return {
40
+ id,
41
+ name: typeof name === "string" && name !== "" ? name : null,
42
+ role: typeof role === "string" && ORGANIZATION_ROLES.has(role) ? role : null
43
+ };
44
+ }
45
+ function actor(source) {
46
+ return parseActor(claimsOf(source)["act"], 0);
47
+ }
48
+ function isSupportSession(source) {
49
+ return actor(source) !== null;
50
+ }
51
+ function sessionId(source) {
52
+ const sid = claimsOf(source)["sid"];
53
+ return typeof sid === "string" && sid !== "" ? sid : null;
54
+ }
55
+ function roles(source) {
56
+ return stringList(claimsOf(source)["roles"]);
57
+ }
58
+ function permissions(source) {
59
+ return stringList(claimsOf(source)["permissions"]);
60
+ }
61
+ function hasRole(source, role) {
62
+ return roles(source).includes(role);
63
+ }
64
+ function hasPermission(source, permission) {
65
+ return permissions(source).includes(permission);
66
+ }
67
+ var MAX_ACTOR_DEPTH = 8;
68
+ function parseActor(claim, depth) {
69
+ if (claim === void 0 || claim === null) {
70
+ return null;
71
+ }
72
+ if (typeof claim !== "object" || Array.isArray(claim)) {
73
+ return { sub: null, actor: null };
74
+ }
75
+ const record = claim;
76
+ const sub = record["sub"];
77
+ return {
78
+ sub: typeof sub === "string" && sub !== "" ? sub : null,
79
+ actor: depth + 1 < MAX_ACTOR_DEPTH ? parseActor(record["act"], depth + 1) : null
80
+ };
81
+ }
82
+ function stringList(claim) {
83
+ return Array.isArray(claim) ? claim.filter((value) => typeof value === "string" && value !== "") : [];
84
+ }
85
+ function claimsOf(source) {
86
+ return isUser(source) ? source.claims : source;
87
+ }
88
+ function isUser(source) {
89
+ return typeof source["claims"] === "object" && source["claims"] !== null && typeof source["accessToken"] === "string";
90
+ }
91
+
80
92
  // src/vault.ts
81
93
  var VaultClient = class {
82
94
  constructor(issuer, token, timeoutMs = 1e4) {
@@ -276,26 +288,6 @@ function trimSlash(value) {
276
288
  return value.replace(/\/+$/, "");
277
289
  }
278
290
 
279
- // src/issuer.ts
280
- function assertSecureIssuer(issuer) {
281
- let url;
282
- try {
283
- url = new URL(issuer);
284
- } catch {
285
- throw new ConfigurationError("Cbox ID config `issuer` is not a valid URL.");
286
- }
287
- if (url.protocol === "https:") {
288
- return;
289
- }
290
- const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1";
291
- if (url.protocol === "http:" && loopback) {
292
- return;
293
- }
294
- throw new ConfigurationError(
295
- `Cbox ID config \`issuer\` must be https (got ${url.protocol}//${url.hostname}).`
296
- );
297
- }
298
-
299
291
  // src/client.ts
300
292
  var CboxIdClient = class {
301
293
  constructor(config) {
@@ -306,9 +298,6 @@ var CboxIdClient = class {
306
298
  if (!config.clientId) {
307
299
  throw new ConfigurationError("Cbox ID config `clientId` is required.");
308
300
  }
309
- if (!config.redirectUri) {
310
- throw new ConfigurationError("Cbox ID config `redirectUri` is required.");
311
- }
312
301
  assertSecureIssuer(config.issuer);
313
302
  this.discovery = new Discovery(
314
303
  config.issuer,
@@ -325,21 +314,29 @@ var CboxIdClient = class {
325
314
  * to {@link authenticate} on the callback.
326
315
  */
327
316
  async createAuthorizationRequest(options = {}) {
317
+ const prompt = promptValue(options.prompt);
318
+ assertOrganizationOptions(options, prompt);
328
319
  const codeVerifier = createVerifier();
329
320
  const state = options.state ?? randomToken(16);
330
321
  const nonce = randomToken(16);
322
+ const redirectUri = options.redirectUri ?? this.config.redirectUri;
323
+ if (redirectUri === void 0 || redirectUri === "") {
324
+ throw new ConfigurationError(
325
+ "A `redirectUri` is required to start the browser sign-in flow. Set it in the config, or pass it to createAuthorizationRequest."
326
+ );
327
+ }
331
328
  const params = new URLSearchParams({
332
329
  response_type: "code",
333
330
  client_id: this.config.clientId,
334
- redirect_uri: options.redirectUri ?? this.config.redirectUri,
331
+ redirect_uri: redirectUri,
335
332
  scope: (options.scopes ?? this.scopes()).join(" "),
336
333
  state,
337
334
  nonce,
338
335
  code_challenge: await challenge(codeVerifier),
339
336
  code_challenge_method: "S256"
340
337
  });
341
- if (options.prompt) {
342
- params.set("prompt", options.prompt);
338
+ if (prompt.length > 0) {
339
+ params.set("prompt", prompt.join(" "));
343
340
  }
344
341
  if (options.loginHint) {
345
342
  params.set("login_hint", options.loginHint);
@@ -347,6 +344,12 @@ var CboxIdClient = class {
347
344
  if (typeof options.maxAge === "number") {
348
345
  params.set("max_age", String(options.maxAge));
349
346
  }
347
+ if (options.organization !== void 0) {
348
+ params.set("organization", options.organization);
349
+ }
350
+ if (options.organizationHint !== void 0) {
351
+ params.set("organization_hint", options.organizationHint);
352
+ }
350
353
  const endpoint = await this.discovery.endpoint("authorization_endpoint");
351
354
  return {
352
355
  url: `${endpoint}?${params.toString()}`,
@@ -355,9 +358,27 @@ var CboxIdClient = class {
355
358
  nonce,
356
359
  // Carried through so authenticate() can hold the instance to it. Only present
357
360
  // when requested (exactOptionalPropertyTypes).
358
- ...typeof options.maxAge === "number" ? { maxAge: options.maxAge } : {}
361
+ ...typeof options.maxAge === "number" ? { maxAge: options.maxAge } : {},
362
+ ...options.organization !== void 0 ? { organization: options.organization } : {}
359
363
  };
360
364
  }
365
+ /**
366
+ * Switch the signed-in person to another organization: a new authorization bound to
367
+ * `organizationId`. Persist and redirect exactly as for
368
+ * {@link createAuthorizationRequest} — it is one, with `organization` set.
369
+ *
370
+ * Cbox ID already holds the person's session, so this is normally a redirect there and
371
+ * straight back with no sign-in form. The tokens that come back carry the new `org`,
372
+ * `org_role`, `roles` and `permissions`; replace your session with them rather than
373
+ * patching the old one, because every one of those can differ between organizations.
374
+ *
375
+ * A person who is not (or is no longer) an active member of that organization comes
376
+ * back with `error=access_denied`, which {@link authenticate} throws as an
377
+ * `AuthenticationError` with `error === 'access_denied'`.
378
+ */
379
+ switchOrganization(organizationId, options = {}) {
380
+ return this.createAuthorizationRequest({ ...options, organization: organizationId });
381
+ }
361
382
  /**
362
383
  * Complete login on your callback route: verify state, exchange the code with the
363
384
  * PKCE verifier, verify the id_token, and return the authenticated user.
@@ -372,7 +393,9 @@ var CboxIdClient = class {
372
393
  }
373
394
  if (params.error) {
374
395
  throw new AuthenticationError(
375
- `Cbox ID returned an error: ${params.error}${params.error_description ? ` (${params.error_description})` : ""}`
396
+ `Cbox ID returned an error: ${params.error}${params.error_description ? ` (${params.error_description})` : ""}`,
397
+ params.error,
398
+ params.error_description ?? void 0
376
399
  );
377
400
  }
378
401
  if (!params.code) {
@@ -394,6 +417,24 @@ var CboxIdClient = class {
394
417
  "A max_age was requested but no id_token was returned, so the authentication age could not be verified."
395
418
  );
396
419
  }
420
+ const user = await this.identityFrom(tokens, verified);
421
+ if (stored.organization !== void 0 && user.organizationId !== stored.organization) {
422
+ throw new AuthenticationError(
423
+ `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.`
424
+ );
425
+ }
426
+ return user;
427
+ }
428
+ /**
429
+ * Turn a token response into the verified {@link CboxUser} every flow returns.
430
+ *
431
+ * Shared by the browser flow and the device flow rather than written twice: the checks
432
+ * in here — the UserInfo subject matching the id_token's, the merge that lets UserInfo
433
+ * enrich but never overwrite signed claims — are the ones that decide whether an
434
+ * identity is trustworthy. A second copy is a second place for one of them to be
435
+ * quietly missing, and the flow it was missing from would look exactly as correct.
436
+ */
437
+ async identityFrom(tokens, verified) {
397
438
  const profile = await this.userinfo(tokens.access_token);
398
439
  const verifiedSub = verified["sub"];
399
440
  const profileSub = profile["sub"];
@@ -411,6 +452,11 @@ var CboxIdClient = class {
411
452
  email: typeof claims["email"] === "string" ? claims["email"] : null,
412
453
  name: typeof claims["name"] === "string" ? claims["name"] : null,
413
454
  organizationId: typeof claims["org"] === "string" ? claims["org"] : null,
455
+ organization: organization(claims),
456
+ roles: roles(claims),
457
+ permissions: permissions(claims),
458
+ actor: actor(claims),
459
+ sessionId: sessionId(claims),
414
460
  // Only present when the instance emitted the claim (exactOptionalPropertyTypes).
415
461
  ...organizations ? { organizations } : {},
416
462
  claims,
@@ -420,6 +466,118 @@ var CboxIdClient = class {
420
466
  expiresIn: typeof tokens.expires_in === "number" ? tokens.expires_in : 0
421
467
  };
422
468
  }
469
+ /**
470
+ * Start the device authorization grant (RFC 8628) — the flow for a program with no
471
+ * browser of its own: a CLI, a CI job, a container, a TV.
472
+ *
473
+ * Print `userCode` and `verificationUri`, then call
474
+ * {@link CboxIdClient.pollDeviceToken}. If the machine has a desktop you may also open
475
+ * `verificationUriComplete`, which fills the code in — but print the code anyway: the
476
+ * machine running your program is often not the one the person is looking at.
477
+ *
478
+ * The scopes are bounded by what the app is REGISTERED for. A device request naming one
479
+ * outside that ceiling is refused with `invalid_scope` rather than quietly reduced,
480
+ * because no browser is in front of it to notice a smaller grant.
481
+ *
482
+ * @throws ConfigurationError when the instance advertises no device endpoint.
483
+ */
484
+ async requestDeviceAuthorization(scopes) {
485
+ const endpoint = await this.discovery.optionalEndpoint("device_authorization_endpoint");
486
+ if (endpoint === null) {
487
+ throw new ConfigurationError(
488
+ "This instance does not advertise a device_authorization_endpoint, so it does not support CLI sign-in."
489
+ );
490
+ }
491
+ const body = new URLSearchParams({
492
+ client_id: this.config.clientId,
493
+ scope: (scopes ?? this.scopes()).join(" ")
494
+ });
495
+ if (this.config.clientSecret) {
496
+ body.set("client_secret", this.config.clientSecret);
497
+ }
498
+ const response = await this.post(endpoint, body);
499
+ if (!response.ok) {
500
+ throw await oauthError(response, "Device authorization request failed");
501
+ }
502
+ const payload = await response.json();
503
+ const deviceCode = payload["device_code"];
504
+ const userCode = payload["user_code"];
505
+ const verificationUri = payload["verification_uri"];
506
+ if (typeof deviceCode !== "string" || typeof userCode !== "string" || typeof verificationUri !== "string") {
507
+ throw new AuthenticationError("The device authorization response was incomplete.");
508
+ }
509
+ return {
510
+ deviceCode,
511
+ userCode,
512
+ verificationUri,
513
+ verificationUriComplete: typeof payload["verification_uri_complete"] === "string" ? payload["verification_uri_complete"] : null,
514
+ expiresIn: typeof payload["expires_in"] === "number" ? payload["expires_in"] : 600,
515
+ // RFC 8628 §3.2: absent means 5 seconds. Polling faster than the server allows is
516
+ // answered with `slow_down`, so the default is the one the server assumes too.
517
+ interval: typeof payload["interval"] === "number" ? payload["interval"] : 5
518
+ };
519
+ }
520
+ /**
521
+ * Poll until the person approves, and return them.
522
+ *
523
+ * Blocks. It honours the server's `interval`, backs off permanently by five seconds on
524
+ * `slow_down` (RFC 8628 §3.5), and stops on the three answers that are final: they
525
+ * declined, the code expired, or tokens arrived. Pass an `AbortSignal` to give up
526
+ * early — a CLI should let Ctrl-C work.
527
+ *
528
+ * @throws AuthenticationError when the person declines or the code expires.
529
+ */
530
+ async pollDeviceToken(authorization, options = {}) {
531
+ const endpoint = await this.discovery.endpoint("token_endpoint");
532
+ const deadline = Date.now() + authorization.expiresIn * 1e3;
533
+ let intervalMs = authorization.interval * 1e3;
534
+ for (; ; ) {
535
+ if (options.signal?.aborted === true) {
536
+ throw new AuthenticationError("Device sign-in was cancelled.");
537
+ }
538
+ if (Date.now() > deadline) {
539
+ throw new AuthenticationError(
540
+ "The device code expired before it was approved. Start the sign-in again."
541
+ );
542
+ }
543
+ await sleep(intervalMs, options.signal);
544
+ const body = new URLSearchParams({
545
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
546
+ device_code: authorization.deviceCode,
547
+ client_id: this.config.clientId
548
+ });
549
+ if (this.config.clientSecret) {
550
+ body.set("client_secret", this.config.clientSecret);
551
+ }
552
+ const response = await this.post(endpoint, body);
553
+ if (response.ok) {
554
+ const tokens = await response.json();
555
+ if (!tokens.access_token) {
556
+ throw new AuthenticationError("The device token response carried no access token.");
557
+ }
558
+ const verified = tokens.id_token ? await this.verifyIdToken(tokens.id_token, "") : {};
559
+ return await this.identityFrom(tokens, verified);
560
+ }
561
+ const payload = await response.json().catch(() => ({}));
562
+ const error = typeof payload["error"] === "string" ? payload["error"] : "invalid_request";
563
+ if (error === "authorization_pending") {
564
+ continue;
565
+ }
566
+ if (error === "slow_down") {
567
+ intervalMs += 5e3;
568
+ continue;
569
+ }
570
+ if (error === "access_denied") {
571
+ throw new AuthenticationError("Sign-in was declined.");
572
+ }
573
+ if (error === "expired_token") {
574
+ throw new AuthenticationError(
575
+ "The device code expired before it was approved. Start the sign-in again."
576
+ );
577
+ }
578
+ throw await oauthError(response, "Device sign-in failed");
579
+ }
580
+ }
423
581
  /**
424
582
  * Exchange a refresh token for a fresh access token (OAuth 2.0 `refresh_token`
425
583
  * grant). Cbox ID rotates refresh tokens and detects reuse, so ALWAYS persist the
@@ -467,6 +625,25 @@ var CboxIdClient = class {
467
625
  const base = `${this.config.issuer.replace(/\/$/, "")}${this.accountPath()}`;
468
626
  return returnTo ? `${base}?${new URLSearchParams({ return_to: returnTo }).toString()}` : base;
469
627
  }
628
+ /**
629
+ * The hosted page where a person creates and revokes API keys for your API — Cbox ID's
630
+ * `/account/api-keys`, preselected to this app (or `clientId`). Keys made there are
631
+ * checked with `ApiKeyVerifier` from `@cboxdk/id-js/server`.
632
+ *
633
+ * `returnTo` becomes a link back to your app, honoured only for an origin the app
634
+ * registered. `organization` picks which of the person's organizations the keys act in;
635
+ * omitted, the page uses the one they are in.
636
+ */
637
+ apiKeysUrl(options = {}) {
638
+ const params = new URLSearchParams({ client_id: options.clientId ?? this.config.clientId });
639
+ if (options.returnTo) {
640
+ params.set("return_to", options.returnTo);
641
+ }
642
+ if (options.organization) {
643
+ params.set("organization", options.organization);
644
+ }
645
+ return `${this.config.issuer.replace(/\/$/, "")}/account/api-keys?${params.toString()}`;
646
+ }
470
647
  /**
471
648
  * The RP-initiated logout URL, or null when the instance advertises none.
472
649
  *
@@ -606,10 +783,16 @@ var CboxIdClient = class {
606
783
  return new VaultClient(this.config.issuer, accessToken, this.config.timeoutMs ?? 1e4);
607
784
  }
608
785
  async exchange(code, verifier, redirectUri) {
786
+ const callback = redirectUri ?? this.config.redirectUri;
787
+ if (callback === void 0 || callback === "") {
788
+ throw new ConfigurationError(
789
+ "A `redirectUri` is required to exchange an authorization code. It must be the same one the authorization request used."
790
+ );
791
+ }
609
792
  const body = new URLSearchParams({
610
793
  grant_type: "authorization_code",
611
794
  code,
612
- redirect_uri: redirectUri ?? this.config.redirectUri,
795
+ redirect_uri: callback,
613
796
  client_id: this.config.clientId,
614
797
  code_verifier: verifier
615
798
  });
@@ -693,6 +876,37 @@ var CboxIdClient = class {
693
876
  }
694
877
  }
695
878
  };
879
+ function promptValue(prompt) {
880
+ if (prompt === void 0) {
881
+ return [];
882
+ }
883
+ const values = [...new Set(typeof prompt === "string" ? [prompt] : prompt)];
884
+ if (values.includes("none") && values.length > 1) {
885
+ throw new ConfigurationError("`prompt: 'none'` cannot be combined with another prompt value.");
886
+ }
887
+ return values;
888
+ }
889
+ function assertOrganizationOptions(options, prompt) {
890
+ if (options.organization === "") {
891
+ throw new ConfigurationError("`organization` is empty. Omit it to sign in without binding to an organization.");
892
+ }
893
+ if (options.organizationHint === "") {
894
+ throw new ConfigurationError("`organizationHint` is empty. Omit it when you have no organization to suggest.");
895
+ }
896
+ if (options.organization === void 0) {
897
+ return;
898
+ }
899
+ if (prompt.includes("select_organization")) {
900
+ throw new ConfigurationError(
901
+ "`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."
902
+ );
903
+ }
904
+ if (prompt.includes("create_organization")) {
905
+ throw new ConfigurationError(
906
+ "`organization` binds the sign-in to an existing organization and `prompt: 'create_organization'` creates a new one. Send one or the other."
907
+ );
908
+ }
909
+ }
696
910
  function timingSafeEqualString(a, b) {
697
911
  if (a.length !== b.length) {
698
912
  return false;
@@ -721,7 +935,24 @@ function parseOrganizations(claim) {
721
935
  }
722
936
  return orgs;
723
937
  }
938
+ function sleep(ms, signal) {
939
+ return new Promise((resolve, reject) => {
940
+ if (signal?.aborted === true) {
941
+ reject(new AuthenticationError("Device sign-in was cancelled."));
942
+ return;
943
+ }
944
+ const timer = setTimeout(() => {
945
+ signal?.removeEventListener("abort", onAbort);
946
+ resolve();
947
+ }, ms);
948
+ const onAbort = () => {
949
+ clearTimeout(timer);
950
+ reject(new AuthenticationError("Device sign-in was cancelled."));
951
+ };
952
+ signal?.addEventListener("abort", onAbort, { once: true });
953
+ });
954
+ }
724
955
 
725
- export { AuthenticationError, CboxIdClient, CboxIdError, ConfigurationError, Discovery, FrontendApiError, InvalidStateError, VaultClient, assertSecureIssuer, challenge, createVerifier, randomToken, verifyWebhook };
726
- //# sourceMappingURL=chunk-3V6OKRWF.js.map
727
- //# sourceMappingURL=chunk-3V6OKRWF.js.map
956
+ export { CboxIdClient, Discovery, VaultClient, actor, challenge, createVerifier, hasPermission, hasRole, isSupportSession, organization, permissions, randomToken, roles, sessionId, verifyWebhook };
957
+ //# sourceMappingURL=chunk-G3CD7X4U.js.map
958
+ //# sourceMappingURL=chunk-G3CD7X4U.js.map