@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/README.md CHANGED
@@ -90,6 +90,167 @@ const user = await client.authenticate({
90
90
  });
91
91
  ```
92
92
 
93
+ ## Organizations
94
+
95
+ A sign-in can be bound to one organization. The tokens then carry `org`, `org_name`, the
96
+ person's membership tier in it (`org_role`), and the app `roles` / `permissions` they hold
97
+ **there** — so switching organization means a new authorization, not a flag on the old
98
+ session.
99
+
100
+ ```ts
101
+ // Bind to an organization you already know (the person must be an active member):
102
+ await client.createAuthorizationRequest({ organization: 'org_2x…' });
103
+
104
+ // Always show the hosted organization picker, with your guess preselected:
105
+ await client.createAuthorizationRequest({
106
+ prompt: 'select_organization',
107
+ organizationHint: 'org_2x…',
108
+ });
109
+
110
+ // Hosted "create a team" step; the person becomes its owner and the sign-in
111
+ // continues bound to the new organization:
112
+ await client.createAuthorizationRequest({ prompt: 'create_organization' });
113
+ ```
114
+
115
+ | Option | Sent as | Meaning |
116
+ |---|---|---|
117
+ | `organization` | `organization` | Bind the sign-in to this organization. |
118
+ | `organizationHint` | `organization_hint` | Preselect it in the picker; the person may choose another. |
119
+ | `prompt: 'select_organization'` | `prompt=select_organization` | Always show the picker. |
120
+ | `prompt: 'create_organization'` | `prompt=create_organization` | Create an organization first. |
121
+
122
+ `organization` cannot be combined with either organization prompt — it has already made
123
+ the choice they ask the person to make — and the SDK refuses the combination rather than
124
+ sending it. Use `organizationHint` with the picker instead.
125
+
126
+ ### Switching
127
+
128
+ `switchOrganization(id)` is `createAuthorizationRequest({ organization: id })` under a name
129
+ that says what it is for. Persist and redirect exactly as for a sign-in; Cbox ID already
130
+ has the person's session, so they normally come straight back without seeing a form.
131
+
132
+ ```ts
133
+ // app/auth/switch-organization/route.ts (Next.js)
134
+ import { NextResponse, type NextRequest } from 'next/server';
135
+ import { cboxId } from '@/lib/cbox';
136
+
137
+ export async function GET(request: NextRequest) {
138
+ const org = request.nextUrl.searchParams.get('org');
139
+ if (!org) return NextResponse.redirect(new URL('/', request.url));
140
+ return cboxId.switchOrganization(org);
141
+ }
142
+ ```
143
+
144
+ **The binding is checked, not trusted.** The request echoes `organization`; persist it with
145
+ `state`, `codeVerifier` and `nonce` and pass it back as `stored.organization`, and
146
+ `authenticate()` refuses tokens for any other organization. (The Next.js adapter does this
147
+ in a cookie for you.) An instance that predates organization selection ignores the
148
+ parameter and answers for whichever organization the session already had — without the
149
+ check, your app would show the new organization's name over the old one's data.
150
+
151
+ Replace your session with the user the callback returns rather than patching the old one:
152
+ `org_role`, `roles` and `permissions` can all differ between organizations. A person who
153
+ is not (or no longer) an active member comes back with `error=access_denied`:
154
+
155
+ ```ts
156
+ import { AuthenticationError } from '@cboxdk/id-js';
157
+
158
+ try {
159
+ user = await cboxId.callback(request);
160
+ } catch (e) {
161
+ if (e instanceof AuthenticationError && e.error === 'access_denied') {
162
+ // Not a member of that organization — send them back to the one they were in.
163
+ }
164
+ }
165
+ ```
166
+
167
+ ### Listing a person's organizations
168
+
169
+ Request the `organizations` scope and `user.organizations` lists every organization the
170
+ person is an active member of — `{ id, name, role }` — for an organization switcher. It is
171
+ a separate scope because it discloses memberships across unrelated customers; a plain
172
+ `profile` sign-in does not get it.
173
+
174
+ ### Reading the claims
175
+
176
+ The signed-in user carries them typed:
177
+
178
+ ```ts
179
+ user.organization; // { id, name, role } | null — role is 'owner' | 'admin' | 'developer' | 'member' | 'viewer' | null
180
+ user.roles; // string[]
181
+ user.permissions; // string[]
182
+ user.actor; // { sub, actor } | null — see support sessions below
183
+ user.sessionId; // the id_token's `sid` | null — keep it to match a back-channel logout
184
+ ```
185
+
186
+ The same helpers work on the user and on a claim set you verified yourself, such as an
187
+ access token's payload on a resource server:
188
+
189
+ ```ts
190
+ import { organization, hasPermission, hasRole, isSupportSession } from '@cboxdk/id-js';
191
+
192
+ if (!hasPermission(payload, 'invoices:create')) return forbidden();
193
+ if (organization(user)?.role === 'owner') showBilling();
194
+ ```
195
+
196
+ Matching is exact — `invoices:*` does not grant `invoices:delete`. An `org_role` this SDK
197
+ version does not recognise reads as `null`, never as a tier it would have to guess.
198
+
199
+ ### Support sessions
200
+
201
+ A staff member can act as one of your users for a limited time (at most an hour, no refresh
202
+ token, with a recorded reason). Those tokens carry the RFC 8693 `act` claim naming the
203
+ staff member, and `isSupportSession()` reports it:
204
+
205
+ ```ts
206
+ if (isSupportSession(user)) {
207
+ // Show a banner, and refuse what a helper should never do on someone's behalf:
208
+ // changing their password, their email, their payout details.
209
+ }
210
+ ```
211
+
212
+ It is **fail-closed**: any `act` claim counts, including one whose shape the SDK cannot
213
+ read (`user.actor.sub` is then `null`). A claim it cannot parse is not evidence that nobody
214
+ else is at the keyboard.
215
+
216
+ ## From a CLI (device flow)
217
+
218
+ A command-line tool has no browser to redirect, and neither does a CI job, a container or
219
+ a TV app. The device authorization grant (RFC 8628) is for all of them: your program
220
+ prints a short code, the person approves it on whatever device is already in their hand,
221
+ and your program collects the tokens.
222
+
223
+ Register the app as **"CLI or device"** in the console. It is issued **no secret** — a
224
+ binary on somebody's laptop cannot keep one — and has **no redirect URI**, so neither
225
+ appears here:
226
+
227
+ ```ts
228
+ import { CboxIdClient } from '@cboxdk/id-js';
229
+
230
+ const cbox = new CboxIdClient({
231
+ issuer: process.env.CBOX_ID_ISSUER!,
232
+ clientId: process.env.CBOX_ID_CLIENT_ID!,
233
+ scopes: ['openid', 'profile', 'email', 'offline_access'],
234
+ });
235
+
236
+ const auth = await cbox.requestDeviceAuthorization();
237
+ console.log(`Open ${auth.verificationUri} and enter ${auth.userCode}`);
238
+
239
+ // Blocks until they approve. Honours the server's interval, backs off on `slow_down`,
240
+ // and stops on a decline or an expired code. Pass an AbortSignal so Ctrl-C works.
241
+ const user = await cbox.pollDeviceToken(auth);
242
+ console.log(`Signed in as ${user.email}`);
243
+ // Persist user.refreshToken (mode 0600) so the next run does not ask again.
244
+ ```
245
+
246
+ The scopes are bounded by what the app is **registered** for: a device request naming one
247
+ outside that ceiling is refused with `invalid_scope` rather than quietly reduced, because
248
+ no browser is in front of it to notice a smaller grant. Keep `offline_access` unless you
249
+ want the person re-approving every hour.
250
+
251
+ See [Sign in from a CLI](https://github.com/cboxdk/cbox-id/blob/main/docs/getting-started/sign-in-from-a-cli.md)
252
+ for the protocol itself and where to store the tokens.
253
+
93
254
  ## In the browser (publishable keys)
94
255
 
95
256
  Everything above needs a server: it holds your client secret. A **publishable key** is the
@@ -239,6 +400,47 @@ everywhere" needs. `machineToken`, `introspect` and `revoke` authenticate as the
239
400
  client, so they require a `clientSecret`; `userinfo` authenticates with the user's
240
401
  own access token and does not.
241
402
 
403
+ ## API keys for your API
404
+
405
+ Your customers can create API keys for **your** API on Cbox ID's hosted page, and your API
406
+ asks Cbox ID whether a key it was handed is good. Link people to the page:
407
+
408
+ ```ts
409
+ client.apiKeysUrl({ returnTo: 'https://app.acme.com/settings' });
410
+ // → {issuer}/account/api-keys?client_id=<your client>&return_to=…
411
+ // options: clientId (another of your apps), returnTo, organization (which of theirs)
412
+ ```
413
+
414
+ Verify a key on your server — never in a browser, since it uses your client secret. It
415
+ lives in its own entry, `@cboxdk/id-js/server`, so a browser bundle never pulls it in:
416
+
417
+ ```ts
418
+ import { ApiKeyVerifier } from '@cboxdk/id-js/server';
419
+ import { hasPermission } from '@cboxdk/id-js';
420
+
421
+ const keys = new ApiKeyVerifier({ issuer, clientId, clientSecret, cacheTtlMs: 10_000 });
422
+
423
+ const answer = await keys.verifyApiKey(request.headers.get('x-api-key') ?? '');
424
+ if (!answer.active) return new Response(null, { status: 401 });
425
+ if (!hasPermission(answer, 'invoices:create')) return new Response(null, { status: 403 });
426
+ // answer: { active, key_id, sub, org, org_role, permissions, client_id, expires_at }
427
+ ```
428
+
429
+ On Next.js the adapter does the same with its own configuration:
430
+ `await cboxId.verifyApiKey(key)` and `cboxId.apiKeysUrl()`.
431
+
432
+ - Every bad key — unknown, revoked, expired, another app's, a holder who left the
433
+ organization — is `{ active: false }`, with no reason. That is Cbox ID's design, so the
434
+ endpoint cannot be used to probe keys.
435
+ - `permissions` is already re-capped to what the holder holds for your app **now**; a
436
+ demotion takes effect on the next verification.
437
+ - An answer naming another app's `client_id` is refused with an `AuthenticationError`, as
438
+ is a failed call (wrong client credentials, instance unreachable). Treat a throw as "do
439
+ not let this request in".
440
+ - `cacheTtlMs` (off by default, at most 60 seconds) caches **active** answers per key, never
441
+ past the key's `expires_at`. While an answer is cached, a revoked key keeps working —
442
+ keep it short. Refusals are never cached.
443
+
242
444
  ## Migrating off an old login
243
445
 
244
446
  Bulk-importing users with their existing hashes is the first answer, and the better one.
@@ -334,8 +536,8 @@ claims for you to enforce. Requires the app's client to hold the `apps.manifest`
334
536
  ```ts
335
537
  import { defineAuthz, publishManifest } from '@cboxdk/id-js';
336
538
 
337
- // Declare the catalog (validated: keys are `feature:action`, roles must reference
338
- // declared permissions). Keep this next to the code that enforces it.
539
+ // Declare the catalog (validated: keys are lowercase `feature:action` slugs, and roles
540
+ // must reference declared permissions). Keep this next to the code that enforces it.
339
541
  export const authz = defineAuthz({
340
542
  permissions: [
341
543
  { key: 'invoices:create', description: 'Create invoices' },
@@ -360,6 +562,39 @@ const summary = await publishManifest(
360
562
  // → { unchanged, roles_declared, permissions_declared, ... }
361
563
  ```
362
564
 
565
+ ### Staff roles and self-serve permissions
566
+
567
+ Two flags decide who may hand something out:
568
+
569
+ - **`tenantAssignable: false` on a role** makes it a **staff role** — for your own support
570
+ or operations people, held environment-wide across every customer. Cbox ID never lists
571
+ or accepts it on an organization's own admin pages; only an environment administrator
572
+ can grant it. Roles default to `true`.
573
+ - **`tenantAssignable: true` on a permission** lets an organization's administrators put it
574
+ in custom roles they build themselves. Permissions default to `false`: internal unless
575
+ you opt in.
576
+
577
+ ```ts
578
+ export const authz = defineAuthz({
579
+ permissions: [
580
+ { key: 'parcels:read', description: 'View parcels', tenantAssignable: true },
581
+ // Lets a staff member start a support session in this app (see "Support sessions").
582
+ { key: 'support:impersonate', description: 'Act as a customer' },
583
+ ],
584
+ roles: [
585
+ { key: 'viewer', name: 'Viewer', permissions: ['parcels:read'] },
586
+ { key: 'support', name: 'Support', description: 'Our support team',
587
+ permissions: ['support:impersonate', 'parcels:read'], tenantAssignable: false },
588
+ ],
589
+ });
590
+ ```
591
+
592
+ On the wire the flags are `tenant_assignable`, and only their non-default state is sent
593
+ (`false` on a role, `true` on a permission). Both are part of the manifest's `version`, so
594
+ marking a role staff-only in a new deploy re-syncs it rather than being skipped as
595
+ unchanged. A value that is not a real boolean — the string `"false"` from a YAML file, say
596
+ — is refused, because it would otherwise read as the opposite of what it says.
597
+
363
598
  `publishManifest` mints a client-credentials token (`scope=apps.manifest`) and POSTs
364
599
  the manifest to `{issuer}/api/v1/apps/manifest`. It is a server-side operation — keep
365
600
  your `clientSecret` off the browser. The wire format matches the PHP SDK
@@ -0,0 +1,81 @@
1
+ // src/errors.ts
2
+ var CboxIdError = class extends Error {
3
+ constructor(message) {
4
+ super(message);
5
+ this.name = new.target.name;
6
+ }
7
+ };
8
+ var ConfigurationError = class extends CboxIdError {
9
+ };
10
+ var InvalidStateError = class extends CboxIdError {
11
+ };
12
+ var AuthenticationError = class extends CboxIdError {
13
+ constructor(message, error, errorDescription, status, retryAfter) {
14
+ super(message);
15
+ this.error = error;
16
+ this.errorDescription = errorDescription;
17
+ this.status = status;
18
+ this.retryAfter = retryAfter;
19
+ }
20
+ error;
21
+ errorDescription;
22
+ status;
23
+ retryAfter;
24
+ /** Whether waiting `retryAfter` seconds and repeating the same request is worth it. */
25
+ get isRateLimited() {
26
+ return this.status === 429;
27
+ }
28
+ };
29
+ async function oauthError(response, fallback) {
30
+ const body = await response.text().catch(() => "");
31
+ let code;
32
+ let description;
33
+ try {
34
+ const parsed = JSON.parse(body);
35
+ if (typeof parsed === "object" && parsed !== null) {
36
+ const { error, error_description: errorDescription } = parsed;
37
+ code = typeof error === "string" ? error : void 0;
38
+ description = typeof errorDescription === "string" ? errorDescription : void 0;
39
+ }
40
+ } catch {
41
+ }
42
+ const detail = code ?? `HTTP ${response.status}`;
43
+ const retryAfterHeader = response.headers.get("retry-after");
44
+ const retryAfter = retryAfterHeader !== null && /^\d+$/.test(retryAfterHeader.trim()) ? Number(retryAfterHeader.trim()) : void 0;
45
+ return new AuthenticationError(`${fallback}: ${detail}`, code, description, response.status, retryAfter);
46
+ }
47
+ var FrontendApiError = class extends CboxIdError {
48
+ constructor(message, code, status, retryAfter) {
49
+ super(message);
50
+ this.code = code;
51
+ this.status = status;
52
+ this.retryAfter = retryAfter;
53
+ }
54
+ code;
55
+ status;
56
+ retryAfter;
57
+ };
58
+
59
+ // src/issuer.ts
60
+ function assertSecureIssuer(issuer) {
61
+ let url;
62
+ try {
63
+ url = new URL(issuer);
64
+ } catch {
65
+ throw new ConfigurationError("Cbox ID config `issuer` is not a valid URL.");
66
+ }
67
+ if (url.protocol === "https:") {
68
+ return;
69
+ }
70
+ const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1";
71
+ if (url.protocol === "http:" && loopback) {
72
+ return;
73
+ }
74
+ throw new ConfigurationError(
75
+ `Cbox ID config \`issuer\` must be https (got ${url.protocol}//${url.hostname}).`
76
+ );
77
+ }
78
+
79
+ export { AuthenticationError, CboxIdError, ConfigurationError, FrontendApiError, InvalidStateError, assertSecureIssuer, oauthError };
80
+ //# sourceMappingURL=chunk-ASOQN47M.js.map
81
+ //# sourceMappingURL=chunk-ASOQN47M.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/errors.ts","../src/issuer.ts"],"names":[],"mappings":";AAIO,IAAM,WAAA,GAAN,cAA0B,KAAA,CAAM;AAAA,EACrC,YAAY,OAAA,EAAiB;AAC3B,IAAA,KAAA,CAAM,OAAO,CAAA;AACb,IAAA,IAAA,CAAK,OAAO,GAAA,CAAA,MAAA,CAAW,IAAA;AAAA,EACzB;AACF;AAGO,IAAM,kBAAA,GAAN,cAAiC,WAAA,CAAY;AAAC;AAM9C,IAAM,iBAAA,GAAN,cAAgC,WAAA,CAAY;AAAC;AAa7C,IAAM,mBAAA,GAAN,cAAkC,WAAA,CAAY;AAAA,EACnD,WAAA,CACE,OAAA,EAES,KAAA,EAEA,gBAAA,EAEA,QAUA,UAAA,EACT;AACA,IAAA,KAAA,CAAM,OAAO,CAAA;AAhBJ,IAAA,IAAA,CAAA,KAAA,GAAA,KAAA;AAEA,IAAA,IAAA,CAAA,gBAAA,GAAA,gBAAA;AAEA,IAAA,IAAA,CAAA,MAAA,GAAA,MAAA;AAUA,IAAA,IAAA,CAAA,UAAA,GAAA,UAAA;AAAA,EAGX;AAAA,EAjBW,KAAA;AAAA,EAEA,gBAAA;AAAA,EAEA,MAAA;AAAA,EAUA,UAAA;AAAA;AAAA,EAMX,IAAI,aAAA,GAAyB;AAC3B,IAAA,OAAO,KAAK,MAAA,KAAW,GAAA;AAAA,EACzB;AACF;AAUA,eAAsB,UAAA,CAAW,UAAoB,QAAA,EAAgD;AACnG,EAAA,MAAM,OAAO,MAAM,QAAA,CAAS,MAAK,CAAE,KAAA,CAAM,MAAM,EAAE,CAAA;AAEjD,EAAA,IAAI,IAAA;AACJ,EAAA,IAAI,WAAA;AAEJ,EAAA,IAAI;AACF,IAAA,MAAM,MAAA,GAAkB,IAAA,CAAK,KAAA,CAAM,IAAI,CAAA;AAEvC,IAAA,IAAI,OAAO,MAAA,KAAW,QAAA,IAAY,MAAA,KAAW,IAAA,EAAM;AACjD,MAAA,MAAM,EAAE,KAAA,EAAO,iBAAA,EAAmB,gBAAA,EAAiB,GAAI,MAAA;AACvD,MAAA,IAAA,GAAO,OAAO,KAAA,KAAU,QAAA,GAAW,KAAA,GAAQ,KAAA,CAAA;AAC3C,MAAA,WAAA,GAAc,OAAO,gBAAA,KAAqB,QAAA,GAAW,gBAAA,GAAmB,KAAA,CAAA;AAAA,IAC1E;AAAA,EACF,CAAA,CAAA,MAAQ;AAAA,EAER;AAOA,EAAA,MAAM,MAAA,GAAS,IAAA,IAAQ,CAAA,KAAA,EAAQ,QAAA,CAAS,MAAM,CAAA,CAAA;AAM9C,EAAA,MAAM,gBAAA,GAAmB,QAAA,CAAS,OAAA,CAAQ,GAAA,CAAI,aAAa,CAAA;AAC3D,EAAA,MAAM,UAAA,GAAa,gBAAA,KAAqB,IAAA,IAAQ,OAAA,CAAQ,IAAA,CAAK,gBAAA,CAAiB,IAAA,EAAM,CAAA,GAChF,MAAA,CAAO,gBAAA,CAAiB,IAAA,EAAM,CAAA,GAC9B,MAAA;AAEJ,EAAA,OAAO,IAAI,mBAAA,CAAoB,CAAA,EAAG,QAAQ,CAAA,EAAA,EAAK,MAAM,CAAA,CAAA,EAAI,IAAA,EAAM,WAAA,EAAa,QAAA,CAAS,MAAA,EAAQ,UAAU,CAAA;AACzG;AAiBO,IAAM,gBAAA,GAAN,cAA+B,WAAA,CAAY;AAAA,EAChD,WAAA,CACE,OAAA,EACS,IAAA,EACA,MAAA,EACA,UAAA,EACT;AACA,IAAA,KAAA,CAAM,OAAO,CAAA;AAJJ,IAAA,IAAA,CAAA,IAAA,GAAA,IAAA;AACA,IAAA,IAAA,CAAA,MAAA,GAAA,MAAA;AACA,IAAA,IAAA,CAAA,UAAA,GAAA,UAAA;AAAA,EAGX;AAAA,EALW,IAAA;AAAA,EACA,MAAA;AAAA,EACA,UAAA;AAIb;;;ACpHO,SAAS,mBAAmB,MAAA,EAAsB;AACvD,EAAA,IAAI,GAAA;AAEJ,EAAA,IAAI;AACF,IAAA,GAAA,GAAM,IAAI,IAAI,MAAM,CAAA;AAAA,EACtB,CAAA,CAAA,MAAQ;AACN,IAAA,MAAM,IAAI,mBAAmB,6CAA6C,CAAA;AAAA,EAC5E;AAEA,EAAA,IAAI,GAAA,CAAI,aAAa,QAAA,EAAU;AAC7B,IAAA;AAAA,EACF;AAEA,EAAA,MAAM,QAAA,GAAW,GAAA,CAAI,QAAA,KAAa,WAAA,IAAe,GAAA,CAAI,QAAA,KAAa,WAAA,IAAe,GAAA,CAAI,QAAA,KAAa,OAAA,IAAW,GAAA,CAAI,QAAA,KAAa,KAAA;AAE9H,EAAA,IAAI,GAAA,CAAI,QAAA,KAAa,OAAA,IAAW,QAAA,EAAU;AACxC,IAAA;AAAA,EACF;AAEA,EAAA,MAAM,IAAI,kBAAA;AAAA,IACR,CAAA,6CAAA,EAAgD,GAAA,CAAI,QAAQ,CAAA,EAAA,EAAK,IAAI,QAAQ,CAAA,EAAA;AAAA,GAC/E;AACF","file":"chunk-ASOQN47M.js","sourcesContent":["/**\n * Base class for every error this SDK throws, so callers can `catch (e) { if (e\n * instanceof CboxIdError) ... }` in one branch.\n */\nexport class CboxIdError extends Error {\n constructor(message: string) {\n super(message);\n this.name = new.target.name;\n }\n}\n\n/** The client is misconfigured (a required option is missing or empty). */\nexport class ConfigurationError extends CboxIdError {}\n\n/**\n * The login `state` did not match what was stored — the callback is forged or\n * stale. Treat as a fresh start, not an error to surface to the user.\n */\nexport class InvalidStateError extends CboxIdError {}\n\n/**\n * Login could not be completed: the provider returned an error, the callback was\n * missing a code, or a token / id_token failed verification.\n *\n * `error` carries the RFC 6749 §5.2 code the authorization server sent, when it sent one.\n * It used to be discarded at every back-channel boundary, leaving a single message string\n * for outcomes that need opposite responses: `invalid_grant` on a refresh means the\n * session is over and the person has to sign in again, while `temporarily_unavailable`\n * means try the same token in a moment. A caller reduced to matching on prose either\n * retries what can never succeed, or signs out somebody who did not need to be.\n */\nexport class AuthenticationError extends CboxIdError {\n constructor(\n message: string,\n /** RFC 6749 §5.2 error code, when the server sent a parseable one. */\n readonly error?: string,\n /** The server's `error_description`, verbatim. Not end-user copy. */\n readonly errorDescription?: string,\n /** HTTP status, for the cases where the body says nothing useful. */\n readonly status?: number,\n /**\n * Seconds to wait, off the `Retry-After` header — set only on a 429.\n *\n * The distinction it carries is the one a caller most needs and could least infer:\n * a 429 is the ONLY back-channel failure where the same request will succeed\n * unchanged if you simply wait. Every other one needs a different request, or a new\n * sign-in. Without this the SDK reported \"token refresh failed\" and a caller with a\n * retry loop hammered a limiter that was already telling it exactly how long to stop.\n */\n readonly retryAfter?: number,\n ) {\n super(message);\n }\n\n /** Whether waiting `retryAfter` seconds and repeating the same request is worth it. */\n get isRateLimited(): boolean {\n return this.status === 429;\n }\n}\n\n/**\n * Read an OAuth error out of a failed token-endpoint response.\n *\n * Best-effort by design: a 502 from a proxy is HTML, a captive portal is worse, and the\n * caller still needs an error rather than a parse exception. What it must never do is\n * invent a code — an absent or unparseable `error` stays undefined, so\n * `e.error === 'invalid_grant'` is true only because the server said so.\n */\nexport async function oauthError(response: Response, fallback: string): Promise<AuthenticationError> {\n const body = await response.text().catch(() => '');\n\n let code: string | undefined;\n let description: string | undefined;\n\n try {\n const parsed: unknown = JSON.parse(body);\n\n if (typeof parsed === 'object' && parsed !== null) {\n const { error, error_description: errorDescription } = parsed as Record<string, unknown>;\n code = typeof error === 'string' ? error : undefined;\n description = typeof errorDescription === 'string' ? errorDescription : undefined;\n }\n } catch {\n // Not JSON. The status and the truncated body below are all there is to report.\n }\n\n // THE BODY IS NEVER ECHOED. These calls carry `client_secret`, `code_verifier` and\n // refresh tokens in their REQUEST, and a debug proxy, a captive portal or an upstream\n // error page that reflects what it received puts those straight into a thrown message\n // — which production apps log. Only the server's own OAuth error code travels, and\n // failing that, the status alone.\n const detail = code ?? `HTTP ${response.status}`;\n\n // `Retry-After` is seconds here — Laravel's throttler writes the integer form. The\n // HTTP-date form is legal too and is deliberately NOT parsed: guessing at a clock\n // skew is worse than saying nothing, and `status === 429` still tells the caller to\n // back off.\n const retryAfterHeader = response.headers.get('retry-after');\n const retryAfter = retryAfterHeader !== null && /^\\d+$/.test(retryAfterHeader.trim())\n ? Number(retryAfterHeader.trim())\n : undefined;\n\n return new AuthenticationError(`${fallback}: ${detail}`, code, description, response.status, retryAfter);\n}\n\n/**\n * The Frontend API refused, or could not be reached.\n *\n * Carries a `code` because the four ways this goes wrong need four different responses\n * from the caller, and telling them apart from a message string is the kind of thing\n * that works until somebody rewords the message:\n *\n * - `origin_not_allowed` — the overwhelmingly likely cause of a refusal, and a\n * configuration problem the developer fixes once in the console.\n * - `rate_limited` — back off; `retryAfter` says for how long, when the server said.\n * - `unavailable` — the network, or a 5xx. Transient by assumption; already retried.\n * - `malformed` — a 200 whose body is not the document we expect, which means something\n * between the page and us is rewriting responses (a proxy, an extension, a captive\n * portal). Worth distinguishing, because retrying it never helps.\n */\nexport class FrontendApiError extends CboxIdError {\n constructor(\n message: string,\n readonly code: 'origin_not_allowed' | 'rate_limited' | 'unavailable' | 'malformed',\n readonly status?: number,\n readonly retryAfter?: number,\n ) {\n super(message);\n }\n}\n","import { ConfigurationError } from './errors.js';\n\n/**\n * An issuer must be HTTPS.\n *\n * Everything this SDK does with it carries a credential: the authorization code, the PKCE\n * verifier, the client secret, the refresh token. Over `http://` a network attacker reads\n * all of them — and, worse, replaces the discovery document and JWKS, at which point a\n * forged id_token verifies cleanly and the whole verification chain proves nothing.\n *\n * Loopback is allowed because a native app's own callback listener is loopback by\n * definition (RFC 8252) and a dev instance runs there.\n */\nexport function assertSecureIssuer(issuer: string): void {\n let url: URL;\n\n try {\n url = new URL(issuer);\n } catch {\n throw new ConfigurationError('Cbox ID config `issuer` is not a valid URL.');\n }\n\n if (url.protocol === 'https:') {\n return;\n }\n\n const loopback = url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]' || url.hostname === '::1';\n\n if (url.protocol === 'http:' && loopback) {\n return;\n }\n\n throw new ConfigurationError(\n `Cbox ID config \\`issuer\\` must be https (got ${url.protocol}//${url.hostname}).`,\n );\n}\n"]}