@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.
package/dist/nextjs.cjs CHANGED
@@ -52,6 +52,175 @@ async function oauthError(response, fallback) {
52
52
  return new AuthenticationError(`${fallback}: ${detail}`, code, description, response.status, retryAfter);
53
53
  }
54
54
 
55
+ // src/issuer.ts
56
+ function assertSecureIssuer(issuer) {
57
+ let url;
58
+ try {
59
+ url = new URL(issuer);
60
+ } catch {
61
+ throw new ConfigurationError("Cbox ID config `issuer` is not a valid URL.");
62
+ }
63
+ if (url.protocol === "https:") {
64
+ return;
65
+ }
66
+ const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1";
67
+ if (url.protocol === "http:" && loopback) {
68
+ return;
69
+ }
70
+ throw new ConfigurationError(
71
+ `Cbox ID config \`issuer\` must be https (got ${url.protocol}//${url.hostname}).`
72
+ );
73
+ }
74
+
75
+ // src/api-keys.ts
76
+ var MAX_API_KEY_CACHE_TTL_MS = 6e4;
77
+ var MAX_CACHE_ENTRIES = 1e3;
78
+ var ORGANIZATION_ROLES = /* @__PURE__ */ new Set([
79
+ "owner",
80
+ "admin",
81
+ "developer",
82
+ "member",
83
+ "viewer"
84
+ ]);
85
+ var ApiKeyVerifier = class {
86
+ constructor(config) {
87
+ this.config = config;
88
+ if (typeof globalThis.document !== "undefined") {
89
+ throw new ConfigurationError(
90
+ "API key verification uses your client secret and must run on your server, never in a browser."
91
+ );
92
+ }
93
+ if (!config.issuer || !config.clientId || !config.clientSecret) {
94
+ throw new ConfigurationError("Verifying API keys needs issuer, clientId and clientSecret.");
95
+ }
96
+ assertSecureIssuer(config.issuer);
97
+ const cacheTtlMs = config.cacheTtlMs ?? 0;
98
+ if (!Number.isFinite(cacheTtlMs) || cacheTtlMs < 0 || cacheTtlMs > MAX_API_KEY_CACHE_TTL_MS) {
99
+ throw new ConfigurationError(
100
+ `cacheTtlMs must be between 0 and ${MAX_API_KEY_CACHE_TTL_MS}: a cached answer keeps a revoked key working for that long.`
101
+ );
102
+ }
103
+ this.endpoint = `${config.issuer.replace(/\/$/, "")}/oauth/api-keys/verify`;
104
+ this.timeoutMs = config.timeoutMs ?? 1e4;
105
+ this.cacheTtlMs = cacheTtlMs;
106
+ }
107
+ config;
108
+ endpoint;
109
+ timeoutMs;
110
+ cacheTtlMs;
111
+ cache = /* @__PURE__ */ new Map();
112
+ /**
113
+ * Ask Cbox ID whether `key` is good for this app, and what it may do.
114
+ *
115
+ * Resolves `{ active: false }` for every kind of bad key, without a request for an empty
116
+ * one. Throws `AuthenticationError` when the call itself fails (wrong client
117
+ * credentials, an unreachable instance) or when the answer cannot be trusted — one that
118
+ * names another app's `client_id`, or does not have the promised shape. Both mean "do
119
+ * not let this request in"; the difference is that a throw is something to look at.
120
+ */
121
+ async verifyApiKey(key) {
122
+ if (typeof key !== "string" || key.trim() === "") {
123
+ return { active: false };
124
+ }
125
+ const cacheKey = this.cacheTtlMs > 0 ? await digest(key) : null;
126
+ if (cacheKey !== null) {
127
+ const hit = this.cache.get(cacheKey);
128
+ if (hit && hit.until > Date.now()) {
129
+ return hit.answer;
130
+ }
131
+ this.cache.delete(cacheKey);
132
+ }
133
+ const answer = this.parse(await this.request(key));
134
+ if (cacheKey !== null && answer.active) {
135
+ this.remember(cacheKey, answer);
136
+ }
137
+ return answer;
138
+ }
139
+ async request(key) {
140
+ const controller = new AbortController();
141
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
142
+ let response;
143
+ try {
144
+ response = await fetch(this.endpoint, {
145
+ method: "POST",
146
+ headers: {
147
+ authorization: `Basic ${btoa(`${this.config.clientId}:${this.config.clientSecret}`)}`,
148
+ accept: "application/json",
149
+ "content-type": "application/x-www-form-urlencoded"
150
+ },
151
+ body: new URLSearchParams({ key }),
152
+ signal: controller.signal
153
+ });
154
+ } catch (error) {
155
+ throw new AuthenticationError(`API key verification request failed: ${String(error)}`);
156
+ } finally {
157
+ clearTimeout(timer);
158
+ }
159
+ if (!response.ok) {
160
+ throw await oauthError(response, "API key verification failed");
161
+ }
162
+ return response.json().catch(() => null);
163
+ }
164
+ parse(body) {
165
+ if (typeof body !== "object" || body === null) {
166
+ throw new AuthenticationError("API key verification returned a body that is not a JSON object.");
167
+ }
168
+ const record = body;
169
+ if (record["active"] !== true) {
170
+ return { active: false };
171
+ }
172
+ const keyId = record["key_id"];
173
+ const sub = record["sub"];
174
+ const org = record["org"];
175
+ const clientId = record["client_id"];
176
+ const expiresAt = record["expires_at"];
177
+ const permissions2 = record["permissions"];
178
+ if (!nonEmpty(keyId) || !nonEmpty(sub) || !nonEmpty(org) || !nonEmpty(clientId) || !Array.isArray(permissions2) || expiresAt !== null && expiresAt !== void 0 && typeof expiresAt !== "string") {
179
+ throw new AuthenticationError("API key verification returned an active answer without the promised fields.");
180
+ }
181
+ if (clientId !== this.config.clientId) {
182
+ throw new AuthenticationError(
183
+ `API key verification answered for client ${clientId}, not this app (${this.config.clientId}); refusing it.`
184
+ );
185
+ }
186
+ if (typeof expiresAt === "string") {
187
+ const expiry = Date.parse(expiresAt);
188
+ if (Number.isNaN(expiry) || expiry <= Date.now()) {
189
+ return { active: false };
190
+ }
191
+ }
192
+ const role = record["org_role"];
193
+ return {
194
+ active: true,
195
+ key_id: keyId,
196
+ sub,
197
+ org,
198
+ org_role: typeof role === "string" && ORGANIZATION_ROLES.has(role) ? role : null,
199
+ permissions: permissions2.filter((value) => nonEmpty(value)),
200
+ client_id: clientId,
201
+ expires_at: typeof expiresAt === "string" ? expiresAt : null
202
+ };
203
+ }
204
+ remember(cacheKey, answer) {
205
+ const expiry = answer.expires_at === null ? Number.POSITIVE_INFINITY : Date.parse(answer.expires_at);
206
+ const until = Math.min(Date.now() + this.cacheTtlMs, expiry);
207
+ if (this.cache.size >= MAX_CACHE_ENTRIES) {
208
+ const oldest = this.cache.keys().next();
209
+ if (!oldest.done) {
210
+ this.cache.delete(oldest.value);
211
+ }
212
+ }
213
+ this.cache.set(cacheKey, { answer, until });
214
+ }
215
+ };
216
+ function nonEmpty(value) {
217
+ return typeof value === "string" && value !== "";
218
+ }
219
+ async function digest(key) {
220
+ const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(key));
221
+ return Array.from(new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, "0")).join("");
222
+ }
223
+
55
224
  // src/discovery.ts
56
225
  var Discovery = class {
57
226
  constructor(issuer, timeoutMs, cacheTtlMs, now = Date.now) {
@@ -116,26 +285,6 @@ function trimSlash(value) {
116
285
  return value.replace(/\/+$/, "");
117
286
  }
118
287
 
119
- // src/issuer.ts
120
- function assertSecureIssuer(issuer) {
121
- let url;
122
- try {
123
- url = new URL(issuer);
124
- } catch {
125
- throw new ConfigurationError("Cbox ID config `issuer` is not a valid URL.");
126
- }
127
- if (url.protocol === "https:") {
128
- return;
129
- }
130
- const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1";
131
- if (url.protocol === "http:" && loopback) {
132
- return;
133
- }
134
- throw new ConfigurationError(
135
- `Cbox ID config \`issuer\` must be https (got ${url.protocol}//${url.hostname}).`
136
- );
137
- }
138
-
139
288
  // src/pkce.ts
140
289
  function base64url(bytes) {
141
290
  let binary = "";
@@ -151,8 +300,68 @@ function createVerifier() {
151
300
  return randomToken(32);
152
301
  }
153
302
  async function challenge(verifier) {
154
- const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
155
- return base64url(new Uint8Array(digest));
303
+ const digest2 = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
304
+ return base64url(new Uint8Array(digest2));
305
+ }
306
+
307
+ // src/claims.ts
308
+ var ORGANIZATION_ROLES2 = /* @__PURE__ */ new Set([
309
+ "owner",
310
+ "admin",
311
+ "developer",
312
+ "member",
313
+ "viewer"
314
+ ]);
315
+ function organization(source) {
316
+ const claims = claimsOf(source);
317
+ const id = claims["org"];
318
+ if (typeof id !== "string" || id === "") {
319
+ return null;
320
+ }
321
+ const name = claims["org_name"];
322
+ const role = claims["org_role"];
323
+ return {
324
+ id,
325
+ name: typeof name === "string" && name !== "" ? name : null,
326
+ role: typeof role === "string" && ORGANIZATION_ROLES2.has(role) ? role : null
327
+ };
328
+ }
329
+ function actor(source) {
330
+ return parseActor(claimsOf(source)["act"], 0);
331
+ }
332
+ function sessionId(source) {
333
+ const sid = claimsOf(source)["sid"];
334
+ return typeof sid === "string" && sid !== "" ? sid : null;
335
+ }
336
+ function roles(source) {
337
+ return stringList(claimsOf(source)["roles"]);
338
+ }
339
+ function permissions(source) {
340
+ return stringList(claimsOf(source)["permissions"]);
341
+ }
342
+ var MAX_ACTOR_DEPTH = 8;
343
+ function parseActor(claim, depth) {
344
+ if (claim === void 0 || claim === null) {
345
+ return null;
346
+ }
347
+ if (typeof claim !== "object" || Array.isArray(claim)) {
348
+ return { sub: null, actor: null };
349
+ }
350
+ const record = claim;
351
+ const sub = record["sub"];
352
+ return {
353
+ sub: typeof sub === "string" && sub !== "" ? sub : null,
354
+ actor: depth + 1 < MAX_ACTOR_DEPTH ? parseActor(record["act"], depth + 1) : null
355
+ };
356
+ }
357
+ function stringList(claim) {
358
+ return Array.isArray(claim) ? claim.filter((value) => typeof value === "string" && value !== "") : [];
359
+ }
360
+ function claimsOf(source) {
361
+ return isUser(source) ? source.claims : source;
362
+ }
363
+ function isUser(source) {
364
+ return typeof source["claims"] === "object" && source["claims"] !== null && typeof source["accessToken"] === "string";
156
365
  }
157
366
 
158
367
  // src/vault.ts
@@ -300,9 +509,6 @@ var CboxIdClient = class {
300
509
  if (!config.clientId) {
301
510
  throw new ConfigurationError("Cbox ID config `clientId` is required.");
302
511
  }
303
- if (!config.redirectUri) {
304
- throw new ConfigurationError("Cbox ID config `redirectUri` is required.");
305
- }
306
512
  assertSecureIssuer(config.issuer);
307
513
  this.discovery = new Discovery(
308
514
  config.issuer,
@@ -319,21 +525,29 @@ var CboxIdClient = class {
319
525
  * to {@link authenticate} on the callback.
320
526
  */
321
527
  async createAuthorizationRequest(options = {}) {
528
+ const prompt = promptValue(options.prompt);
529
+ assertOrganizationOptions(options, prompt);
322
530
  const codeVerifier = createVerifier();
323
531
  const state = options.state ?? randomToken(16);
324
532
  const nonce = randomToken(16);
533
+ const redirectUri = options.redirectUri ?? this.config.redirectUri;
534
+ if (redirectUri === void 0 || redirectUri === "") {
535
+ throw new ConfigurationError(
536
+ "A `redirectUri` is required to start the browser sign-in flow. Set it in the config, or pass it to createAuthorizationRequest."
537
+ );
538
+ }
325
539
  const params = new URLSearchParams({
326
540
  response_type: "code",
327
541
  client_id: this.config.clientId,
328
- redirect_uri: options.redirectUri ?? this.config.redirectUri,
542
+ redirect_uri: redirectUri,
329
543
  scope: (options.scopes ?? this.scopes()).join(" "),
330
544
  state,
331
545
  nonce,
332
546
  code_challenge: await challenge(codeVerifier),
333
547
  code_challenge_method: "S256"
334
548
  });
335
- if (options.prompt) {
336
- params.set("prompt", options.prompt);
549
+ if (prompt.length > 0) {
550
+ params.set("prompt", prompt.join(" "));
337
551
  }
338
552
  if (options.loginHint) {
339
553
  params.set("login_hint", options.loginHint);
@@ -341,6 +555,12 @@ var CboxIdClient = class {
341
555
  if (typeof options.maxAge === "number") {
342
556
  params.set("max_age", String(options.maxAge));
343
557
  }
558
+ if (options.organization !== void 0) {
559
+ params.set("organization", options.organization);
560
+ }
561
+ if (options.organizationHint !== void 0) {
562
+ params.set("organization_hint", options.organizationHint);
563
+ }
344
564
  const endpoint = await this.discovery.endpoint("authorization_endpoint");
345
565
  return {
346
566
  url: `${endpoint}?${params.toString()}`,
@@ -349,9 +569,27 @@ var CboxIdClient = class {
349
569
  nonce,
350
570
  // Carried through so authenticate() can hold the instance to it. Only present
351
571
  // when requested (exactOptionalPropertyTypes).
352
- ...typeof options.maxAge === "number" ? { maxAge: options.maxAge } : {}
572
+ ...typeof options.maxAge === "number" ? { maxAge: options.maxAge } : {},
573
+ ...options.organization !== void 0 ? { organization: options.organization } : {}
353
574
  };
354
575
  }
576
+ /**
577
+ * Switch the signed-in person to another organization: a new authorization bound to
578
+ * `organizationId`. Persist and redirect exactly as for
579
+ * {@link createAuthorizationRequest} — it is one, with `organization` set.
580
+ *
581
+ * Cbox ID already holds the person's session, so this is normally a redirect there and
582
+ * straight back with no sign-in form. The tokens that come back carry the new `org`,
583
+ * `org_role`, `roles` and `permissions`; replace your session with them rather than
584
+ * patching the old one, because every one of those can differ between organizations.
585
+ *
586
+ * A person who is not (or is no longer) an active member of that organization comes
587
+ * back with `error=access_denied`, which {@link authenticate} throws as an
588
+ * `AuthenticationError` with `error === 'access_denied'`.
589
+ */
590
+ switchOrganization(organizationId, options = {}) {
591
+ return this.createAuthorizationRequest({ ...options, organization: organizationId });
592
+ }
355
593
  /**
356
594
  * Complete login on your callback route: verify state, exchange the code with the
357
595
  * PKCE verifier, verify the id_token, and return the authenticated user.
@@ -366,7 +604,9 @@ var CboxIdClient = class {
366
604
  }
367
605
  if (params.error) {
368
606
  throw new AuthenticationError(
369
- `Cbox ID returned an error: ${params.error}${params.error_description ? ` (${params.error_description})` : ""}`
607
+ `Cbox ID returned an error: ${params.error}${params.error_description ? ` (${params.error_description})` : ""}`,
608
+ params.error,
609
+ params.error_description ?? void 0
370
610
  );
371
611
  }
372
612
  if (!params.code) {
@@ -388,6 +628,24 @@ var CboxIdClient = class {
388
628
  "A max_age was requested but no id_token was returned, so the authentication age could not be verified."
389
629
  );
390
630
  }
631
+ const user = await this.identityFrom(tokens, verified);
632
+ if (stored.organization !== void 0 && user.organizationId !== stored.organization) {
633
+ throw new AuthenticationError(
634
+ `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.`
635
+ );
636
+ }
637
+ return user;
638
+ }
639
+ /**
640
+ * Turn a token response into the verified {@link CboxUser} every flow returns.
641
+ *
642
+ * Shared by the browser flow and the device flow rather than written twice: the checks
643
+ * in here — the UserInfo subject matching the id_token's, the merge that lets UserInfo
644
+ * enrich but never overwrite signed claims — are the ones that decide whether an
645
+ * identity is trustworthy. A second copy is a second place for one of them to be
646
+ * quietly missing, and the flow it was missing from would look exactly as correct.
647
+ */
648
+ async identityFrom(tokens, verified) {
391
649
  const profile = await this.userinfo(tokens.access_token);
392
650
  const verifiedSub = verified["sub"];
393
651
  const profileSub = profile["sub"];
@@ -405,6 +663,11 @@ var CboxIdClient = class {
405
663
  email: typeof claims["email"] === "string" ? claims["email"] : null,
406
664
  name: typeof claims["name"] === "string" ? claims["name"] : null,
407
665
  organizationId: typeof claims["org"] === "string" ? claims["org"] : null,
666
+ organization: organization(claims),
667
+ roles: roles(claims),
668
+ permissions: permissions(claims),
669
+ actor: actor(claims),
670
+ sessionId: sessionId(claims),
408
671
  // Only present when the instance emitted the claim (exactOptionalPropertyTypes).
409
672
  ...organizations ? { organizations } : {},
410
673
  claims,
@@ -414,6 +677,118 @@ var CboxIdClient = class {
414
677
  expiresIn: typeof tokens.expires_in === "number" ? tokens.expires_in : 0
415
678
  };
416
679
  }
680
+ /**
681
+ * Start the device authorization grant (RFC 8628) — the flow for a program with no
682
+ * browser of its own: a CLI, a CI job, a container, a TV.
683
+ *
684
+ * Print `userCode` and `verificationUri`, then call
685
+ * {@link CboxIdClient.pollDeviceToken}. If the machine has a desktop you may also open
686
+ * `verificationUriComplete`, which fills the code in — but print the code anyway: the
687
+ * machine running your program is often not the one the person is looking at.
688
+ *
689
+ * The scopes are bounded by what the app is REGISTERED for. A device request naming one
690
+ * outside that ceiling is refused with `invalid_scope` rather than quietly reduced,
691
+ * because no browser is in front of it to notice a smaller grant.
692
+ *
693
+ * @throws ConfigurationError when the instance advertises no device endpoint.
694
+ */
695
+ async requestDeviceAuthorization(scopes) {
696
+ const endpoint = await this.discovery.optionalEndpoint("device_authorization_endpoint");
697
+ if (endpoint === null) {
698
+ throw new ConfigurationError(
699
+ "This instance does not advertise a device_authorization_endpoint, so it does not support CLI sign-in."
700
+ );
701
+ }
702
+ const body = new URLSearchParams({
703
+ client_id: this.config.clientId,
704
+ scope: (scopes ?? this.scopes()).join(" ")
705
+ });
706
+ if (this.config.clientSecret) {
707
+ body.set("client_secret", this.config.clientSecret);
708
+ }
709
+ const response = await this.post(endpoint, body);
710
+ if (!response.ok) {
711
+ throw await oauthError(response, "Device authorization request failed");
712
+ }
713
+ const payload = await response.json();
714
+ const deviceCode = payload["device_code"];
715
+ const userCode = payload["user_code"];
716
+ const verificationUri = payload["verification_uri"];
717
+ if (typeof deviceCode !== "string" || typeof userCode !== "string" || typeof verificationUri !== "string") {
718
+ throw new AuthenticationError("The device authorization response was incomplete.");
719
+ }
720
+ return {
721
+ deviceCode,
722
+ userCode,
723
+ verificationUri,
724
+ verificationUriComplete: typeof payload["verification_uri_complete"] === "string" ? payload["verification_uri_complete"] : null,
725
+ expiresIn: typeof payload["expires_in"] === "number" ? payload["expires_in"] : 600,
726
+ // RFC 8628 §3.2: absent means 5 seconds. Polling faster than the server allows is
727
+ // answered with `slow_down`, so the default is the one the server assumes too.
728
+ interval: typeof payload["interval"] === "number" ? payload["interval"] : 5
729
+ };
730
+ }
731
+ /**
732
+ * Poll until the person approves, and return them.
733
+ *
734
+ * Blocks. It honours the server's `interval`, backs off permanently by five seconds on
735
+ * `slow_down` (RFC 8628 §3.5), and stops on the three answers that are final: they
736
+ * declined, the code expired, or tokens arrived. Pass an `AbortSignal` to give up
737
+ * early — a CLI should let Ctrl-C work.
738
+ *
739
+ * @throws AuthenticationError when the person declines or the code expires.
740
+ */
741
+ async pollDeviceToken(authorization, options = {}) {
742
+ const endpoint = await this.discovery.endpoint("token_endpoint");
743
+ const deadline = Date.now() + authorization.expiresIn * 1e3;
744
+ let intervalMs = authorization.interval * 1e3;
745
+ for (; ; ) {
746
+ if (options.signal?.aborted === true) {
747
+ throw new AuthenticationError("Device sign-in was cancelled.");
748
+ }
749
+ if (Date.now() > deadline) {
750
+ throw new AuthenticationError(
751
+ "The device code expired before it was approved. Start the sign-in again."
752
+ );
753
+ }
754
+ await sleep(intervalMs, options.signal);
755
+ const body = new URLSearchParams({
756
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
757
+ device_code: authorization.deviceCode,
758
+ client_id: this.config.clientId
759
+ });
760
+ if (this.config.clientSecret) {
761
+ body.set("client_secret", this.config.clientSecret);
762
+ }
763
+ const response = await this.post(endpoint, body);
764
+ if (response.ok) {
765
+ const tokens = await response.json();
766
+ if (!tokens.access_token) {
767
+ throw new AuthenticationError("The device token response carried no access token.");
768
+ }
769
+ const verified = tokens.id_token ? await this.verifyIdToken(tokens.id_token, "") : {};
770
+ return await this.identityFrom(tokens, verified);
771
+ }
772
+ const payload = await response.json().catch(() => ({}));
773
+ const error = typeof payload["error"] === "string" ? payload["error"] : "invalid_request";
774
+ if (error === "authorization_pending") {
775
+ continue;
776
+ }
777
+ if (error === "slow_down") {
778
+ intervalMs += 5e3;
779
+ continue;
780
+ }
781
+ if (error === "access_denied") {
782
+ throw new AuthenticationError("Sign-in was declined.");
783
+ }
784
+ if (error === "expired_token") {
785
+ throw new AuthenticationError(
786
+ "The device code expired before it was approved. Start the sign-in again."
787
+ );
788
+ }
789
+ throw await oauthError(response, "Device sign-in failed");
790
+ }
791
+ }
417
792
  /**
418
793
  * Exchange a refresh token for a fresh access token (OAuth 2.0 `refresh_token`
419
794
  * grant). Cbox ID rotates refresh tokens and detects reuse, so ALWAYS persist the
@@ -461,6 +836,25 @@ var CboxIdClient = class {
461
836
  const base = `${this.config.issuer.replace(/\/$/, "")}${this.accountPath()}`;
462
837
  return returnTo ? `${base}?${new URLSearchParams({ return_to: returnTo }).toString()}` : base;
463
838
  }
839
+ /**
840
+ * The hosted page where a person creates and revokes API keys for your API — Cbox ID's
841
+ * `/account/api-keys`, preselected to this app (or `clientId`). Keys made there are
842
+ * checked with `ApiKeyVerifier` from `@cboxdk/id-js/server`.
843
+ *
844
+ * `returnTo` becomes a link back to your app, honoured only for an origin the app
845
+ * registered. `organization` picks which of the person's organizations the keys act in;
846
+ * omitted, the page uses the one they are in.
847
+ */
848
+ apiKeysUrl(options = {}) {
849
+ const params = new URLSearchParams({ client_id: options.clientId ?? this.config.clientId });
850
+ if (options.returnTo) {
851
+ params.set("return_to", options.returnTo);
852
+ }
853
+ if (options.organization) {
854
+ params.set("organization", options.organization);
855
+ }
856
+ return `${this.config.issuer.replace(/\/$/, "")}/account/api-keys?${params.toString()}`;
857
+ }
464
858
  /**
465
859
  * The RP-initiated logout URL, or null when the instance advertises none.
466
860
  *
@@ -600,10 +994,16 @@ var CboxIdClient = class {
600
994
  return new VaultClient(this.config.issuer, accessToken, this.config.timeoutMs ?? 1e4);
601
995
  }
602
996
  async exchange(code, verifier, redirectUri) {
997
+ const callback = redirectUri ?? this.config.redirectUri;
998
+ if (callback === void 0 || callback === "") {
999
+ throw new ConfigurationError(
1000
+ "A `redirectUri` is required to exchange an authorization code. It must be the same one the authorization request used."
1001
+ );
1002
+ }
603
1003
  const body = new URLSearchParams({
604
1004
  grant_type: "authorization_code",
605
1005
  code,
606
- redirect_uri: redirectUri ?? this.config.redirectUri,
1006
+ redirect_uri: callback,
607
1007
  client_id: this.config.clientId,
608
1008
  code_verifier: verifier
609
1009
  });
@@ -687,6 +1087,37 @@ var CboxIdClient = class {
687
1087
  }
688
1088
  }
689
1089
  };
1090
+ function promptValue(prompt) {
1091
+ if (prompt === void 0) {
1092
+ return [];
1093
+ }
1094
+ const values = [...new Set(typeof prompt === "string" ? [prompt] : prompt)];
1095
+ if (values.includes("none") && values.length > 1) {
1096
+ throw new ConfigurationError("`prompt: 'none'` cannot be combined with another prompt value.");
1097
+ }
1098
+ return values;
1099
+ }
1100
+ function assertOrganizationOptions(options, prompt) {
1101
+ if (options.organization === "") {
1102
+ throw new ConfigurationError("`organization` is empty. Omit it to sign in without binding to an organization.");
1103
+ }
1104
+ if (options.organizationHint === "") {
1105
+ throw new ConfigurationError("`organizationHint` is empty. Omit it when you have no organization to suggest.");
1106
+ }
1107
+ if (options.organization === void 0) {
1108
+ return;
1109
+ }
1110
+ if (prompt.includes("select_organization")) {
1111
+ throw new ConfigurationError(
1112
+ "`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."
1113
+ );
1114
+ }
1115
+ if (prompt.includes("create_organization")) {
1116
+ throw new ConfigurationError(
1117
+ "`organization` binds the sign-in to an existing organization and `prompt: 'create_organization'` creates a new one. Send one or the other."
1118
+ );
1119
+ }
1120
+ }
690
1121
  function timingSafeEqualString(a, b) {
691
1122
  if (a.length !== b.length) {
692
1123
  return false;
@@ -715,6 +1146,23 @@ function parseOrganizations(claim) {
715
1146
  }
716
1147
  return orgs;
717
1148
  }
1149
+ function sleep(ms, signal) {
1150
+ return new Promise((resolve, reject) => {
1151
+ if (signal?.aborted === true) {
1152
+ reject(new AuthenticationError("Device sign-in was cancelled."));
1153
+ return;
1154
+ }
1155
+ const timer = setTimeout(() => {
1156
+ signal?.removeEventListener("abort", onAbort);
1157
+ resolve();
1158
+ }, ms);
1159
+ const onAbort = () => {
1160
+ clearTimeout(timer);
1161
+ reject(new AuthenticationError("Device sign-in was cancelled."));
1162
+ };
1163
+ signal?.addEventListener("abort", onAbort, { once: true });
1164
+ });
1165
+ }
718
1166
 
719
1167
  // src/nextjs.ts
720
1168
  var COOKIE = {
@@ -723,12 +1171,15 @@ var COOKIE = {
723
1171
  nonce: "cbox_id_nonce",
724
1172
  // The step-up requirement has to survive the redirect like the nonce does: a
725
1173
  // `maxAge` the callback cannot see is a `maxAge` nothing verifies.
726
- maxAge: "cbox_id_max_age"
1174
+ maxAge: "cbox_id_max_age",
1175
+ // Likewise the organization a switch bound to: the callback refuses tokens for another.
1176
+ organization: "cbox_id_organization"
727
1177
  };
728
1178
  var TEMP_COOKIE_MAX_AGE = 600;
729
1179
  function createCboxId(config) {
730
1180
  const resolved = resolveConfig(config);
731
1181
  const client = new CboxIdClient(resolved);
1182
+ let apiKeys;
732
1183
  const tempCookieOptions = {
733
1184
  httpOnly: true,
734
1185
  secure: true,
@@ -736,22 +1187,33 @@ function createCboxId(config) {
736
1187
  path: "/",
737
1188
  maxAge: TEMP_COOKIE_MAX_AGE
738
1189
  };
1190
+ const redirectTo = (request) => {
1191
+ const response = server.NextResponse.redirect(request.url);
1192
+ response.cookies.set(COOKIE.state, request.state, tempCookieOptions);
1193
+ response.cookies.set(COOKIE.verifier, request.codeVerifier, tempCookieOptions);
1194
+ response.cookies.set(COOKIE.nonce, request.nonce, tempCookieOptions);
1195
+ if (typeof request.maxAge === "number") {
1196
+ response.cookies.set(COOKIE.maxAge, String(request.maxAge), tempCookieOptions);
1197
+ }
1198
+ if (request.organization !== void 0) {
1199
+ response.cookies.set(COOKIE.organization, request.organization, tempCookieOptions);
1200
+ } else {
1201
+ response.cookies.delete(COOKIE.organization);
1202
+ }
1203
+ return response;
1204
+ };
739
1205
  return {
740
1206
  client,
741
1207
  async signIn(options = {}) {
742
- const request = await client.createAuthorizationRequest(options);
743
- const response = server.NextResponse.redirect(request.url);
744
- response.cookies.set(COOKIE.state, request.state, tempCookieOptions);
745
- response.cookies.set(COOKIE.verifier, request.codeVerifier, tempCookieOptions);
746
- response.cookies.set(COOKIE.nonce, request.nonce, tempCookieOptions);
747
- if (typeof request.maxAge === "number") {
748
- response.cookies.set(COOKIE.maxAge, String(request.maxAge), tempCookieOptions);
749
- }
750
- return response;
1208
+ return redirectTo(await client.createAuthorizationRequest(options));
1209
+ },
1210
+ async switchOrganization(organizationId, options = {}) {
1211
+ return redirectTo(await client.switchOrganization(organizationId, options));
751
1212
  },
752
1213
  async callback(request) {
753
1214
  const storedMaxAge = request.cookies.get(COOKIE.maxAge)?.value;
754
1215
  const maxAge = storedMaxAge !== void 0 ? Number(storedMaxAge) : void 0;
1216
+ const organization2 = request.cookies.get(COOKIE.organization)?.value;
755
1217
  return client.authenticate({
756
1218
  params: {
757
1219
  code: request.nextUrl.searchParams.get("code"),
@@ -763,7 +1225,8 @@ function createCboxId(config) {
763
1225
  state: request.cookies.get(COOKIE.state)?.value ?? "",
764
1226
  codeVerifier: request.cookies.get(COOKIE.verifier)?.value ?? "",
765
1227
  nonce: request.cookies.get(COOKIE.nonce)?.value ?? "",
766
- ...maxAge !== void 0 && Number.isFinite(maxAge) ? { maxAge } : {}
1228
+ ...maxAge !== void 0 && Number.isFinite(maxAge) ? { maxAge } : {},
1229
+ ...organization2 !== void 0 && organization2 !== "" ? { organization: organization2 } : {}
767
1230
  }
768
1231
  });
769
1232
  },
@@ -773,6 +1236,18 @@ function createCboxId(config) {
773
1236
  profileRedirect(returnTo) {
774
1237
  return server.NextResponse.redirect(client.profileUrl(returnTo));
775
1238
  },
1239
+ apiKeysUrl(options) {
1240
+ return client.apiKeysUrl(options);
1241
+ },
1242
+ verifyApiKey(key) {
1243
+ apiKeys ??= new ApiKeyVerifier({
1244
+ issuer: resolved.issuer,
1245
+ clientId: resolved.clientId,
1246
+ clientSecret: resolved.clientSecret ?? "",
1247
+ ...resolved.timeoutMs ? { timeoutMs: resolved.timeoutMs } : {}
1248
+ });
1249
+ return apiKeys.verifyApiKey(key);
1250
+ },
776
1251
  signOutUrl(returnTo, idTokenHint) {
777
1252
  return client.logoutUrl(returnTo, idTokenHint);
778
1253
  }