@cboxdk/id-js 0.17.0 → 0.19.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 +315 -3
- package/dist/{chunk-WYTGLCMF.js → chunk-2E25IPBA.js} +4 -3
- package/dist/chunk-2E25IPBA.js.map +1 -0
- package/dist/chunk-QQW63PGU.js +25 -0
- package/dist/chunk-QQW63PGU.js.map +1 -0
- package/dist/{chunk-ASOQN47M.js → chunk-SEUD4TE4.js} +3 -23
- package/dist/chunk-SEUD4TE4.js.map +1 -0
- package/dist/{chunk-G3CD7X4U.js → chunk-YGLMLIKL.js} +197 -9
- package/dist/chunk-YGLMLIKL.js.map +1 -0
- package/dist/{client-CdwdigzG.d.ts → client-BT-jOm0p.d.cts} +154 -2
- package/dist/{client-CEXddiCR.d.cts → client-xlEJalu8.d.ts} +154 -2
- package/dist/errors-D61xljZE.d.cts +87 -0
- package/dist/errors-D61xljZE.d.ts +87 -0
- package/dist/index.cjs +267 -17
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +84 -92
- package/dist/index.d.ts +84 -92
- package/dist/index.js +66 -13
- package/dist/index.js.map +1 -1
- package/dist/management.cjs +5092 -0
- package/dist/management.cjs.map +1 -0
- package/dist/management.d.cts +13454 -0
- package/dist/management.d.ts +13454 -0
- package/dist/management.js +5054 -0
- package/dist/management.js.map +1 -0
- package/dist/nextjs.cjs +136 -5
- package/dist/nextjs.cjs.map +1 -1
- package/dist/nextjs.d.cts +3 -2
- package/dist/nextjs.d.ts +3 -2
- package/dist/nextjs.js +4 -3
- package/dist/nextjs.js.map +1 -1
- package/dist/server.d.cts +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.js +3 -2
- package/dist/{types-BYZldock.d.cts → types-CC1bz68x.d.cts} +30 -1
- package/dist/{types-BYZldock.d.ts → types-CC1bz68x.d.ts} +30 -1
- package/package.json +9 -2
- package/dist/chunk-ASOQN47M.js.map +0 -1
- package/dist/chunk-G3CD7X4U.js.map +0 -1
- package/dist/chunk-WYTGLCMF.js.map +0 -1
package/README.md
CHANGED
|
@@ -196,6 +196,29 @@ if (organization(user)?.role === 'owner') showBilling();
|
|
|
196
196
|
Matching is exact — `invoices:*` does not grant `invoices:delete`. An `org_role` this SDK
|
|
197
197
|
version does not recognise reads as `null`, never as a tier it would have to guess.
|
|
198
198
|
|
|
199
|
+
### Feature flags
|
|
200
|
+
|
|
201
|
+
Request the `feature_flags` scope (and give the app that scope on its Scopes tab) and the
|
|
202
|
+
tokens and UserInfo carry the keys of every flag that is on for the person, in the
|
|
203
|
+
organization they signed in to:
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
import { CboxIdClient, FEATURE_FLAGS_SCOPE, hasFeature } from '@cboxdk/id-js';
|
|
207
|
+
|
|
208
|
+
const client = new CboxIdClient({ ...config, scopes: ['openid', 'profile', 'email', FEATURE_FLAGS_SCOPE] });
|
|
209
|
+
|
|
210
|
+
user.featureFlags; // ['acme-beta', 'new-dashboard'] | null
|
|
211
|
+
if (hasFeature(user, 'new-dashboard')) showNewDashboard();
|
|
212
|
+
hasFeature(accessTokenPayload, 'billing.v2'); // works on a verified claim set too
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
`null` means the claim is absent — the scope was not requested — and `[]` means nothing is
|
|
216
|
+
on. Either way `hasFeature()` is `false`: a missing scope turns every feature off, never on.
|
|
217
|
+
A token carries the flags as they were when it was issued; the next refresh picks up a
|
|
218
|
+
change. To ask without a token in hand (a job, a webhook handler), use
|
|
219
|
+
`env.featureFlags.evaluate({ user_id, organization_id })` from the
|
|
220
|
+
[Management API](#management-api).
|
|
221
|
+
|
|
199
222
|
### Support sessions
|
|
200
223
|
|
|
201
224
|
A staff member can act as one of your users for a limited time (at most an hour, no refresh
|
|
@@ -266,7 +289,10 @@ const frontend = new CboxIdFrontend({
|
|
|
266
289
|
})
|
|
267
290
|
|
|
268
291
|
const config = await frontend.config()
|
|
269
|
-
// → endpoints, social buttons, and the customer's theme
|
|
292
|
+
// → endpoints, social buttons, which methods are on, and the customer's theme
|
|
293
|
+
|
|
294
|
+
// The buttons one organization's hosted page shows (by id or slug):
|
|
295
|
+
const acme = await frontend.config({ organization: 'acme' })
|
|
270
296
|
|
|
271
297
|
const { user } = await frontend.session(accessToken)
|
|
272
298
|
// → { id, email, name } or null
|
|
@@ -355,6 +381,22 @@ if (result.status === 'mfa_required' || result.status === 'otp_required') {
|
|
|
355
381
|
}
|
|
356
382
|
```
|
|
357
383
|
|
|
384
|
+
`mfa_required` also lists the `factors` the person can answer with — `'totp'`, `'sms'`,
|
|
385
|
+
`'recovery_code'` (kinds only, never the number; absent from instances older than
|
|
386
|
+
laravel-id 1.24). For a texted code, send it when the person asks, then submit it as
|
|
387
|
+
`'sms'`:
|
|
388
|
+
|
|
389
|
+
```ts
|
|
390
|
+
if (result.status === 'mfa_required' && result.factors?.includes('sms')) {
|
|
391
|
+
// On "Text me a code" — never on page load: each send spends one of the token's five attempts.
|
|
392
|
+
const sent = await frontend.sendSmsCode(result.mfaToken)
|
|
393
|
+
if (sent.status === 'sent') showCodeField(`We texted ${sent.to}`) // '+45 ******78'
|
|
394
|
+
if (sent.status === 'rate_limited') showRetry(sent.retryAfter)
|
|
395
|
+
// later:
|
|
396
|
+
const done = await frontend.submitSecondFactor(result.mfaToken, code, 'sms')
|
|
397
|
+
}
|
|
398
|
+
```
|
|
399
|
+
|
|
358
400
|
### Passkeys
|
|
359
401
|
|
|
360
402
|
```ts
|
|
@@ -506,6 +548,26 @@ const ok = await verifyWebhook({
|
|
|
506
548
|
});
|
|
507
549
|
```
|
|
508
550
|
|
|
551
|
+
### Standard Webhooks
|
|
552
|
+
|
|
553
|
+
An endpoint registered with `signature_scheme: 'standard_webhooks'` (or switched with
|
|
554
|
+
`env.webhooks.signatureScheme.change(id, { signature_scheme: 'standard_webhooks' })`) is
|
|
555
|
+
signed the [Standard Webhooks](https://www.standardwebhooks.com/) way: `webhook-id`,
|
|
556
|
+
`webhook-timestamp` and `webhook-signature`. Verify it with `verifyStandardWebhook`:
|
|
557
|
+
|
|
558
|
+
```ts
|
|
559
|
+
import { verifyStandardWebhook } from '@cboxdk/id-js';
|
|
560
|
+
|
|
561
|
+
const ok = await verifyStandardWebhook({
|
|
562
|
+
payload: rawBody, // the exact bytes received
|
|
563
|
+
headers: request.headers, // a Headers object or a plain record
|
|
564
|
+
secret: process.env.CBOX_ID_WEBHOOK_SECRET!, // whsec_…, or the endpoint's old 64-hex secret
|
|
565
|
+
});
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
It accepts any valid `v1` signature among several (sent while a secret rotates), uses the same
|
|
569
|
+
5-minute freshness window, and never throws.
|
|
570
|
+
|
|
509
571
|
## Token Vault
|
|
510
572
|
|
|
511
573
|
Broker downstream credentials (API keys for OpenAI, GitHub, …) through the instance's
|
|
@@ -526,6 +588,33 @@ const lease = await agent.lease(secret.id, 'call openai');
|
|
|
526
588
|
|
|
527
589
|
A lease with no live grant is refused — the vault is deny-by-default.
|
|
528
590
|
|
|
591
|
+
### Pipes: a person's own GitHub, Google, Slack… token
|
|
592
|
+
|
|
593
|
+
When a person has connected their account at a provider, lease a fresh access token for it
|
|
594
|
+
with a `vault.lease` token. Cbox ID refreshes it first when it is about to expire:
|
|
595
|
+
|
|
596
|
+
```ts
|
|
597
|
+
import { PipeNotConnectedError, PipeReauthorizationRequiredError, PipeTemporarilyUnavailableError } from '@cboxdk/id-js';
|
|
598
|
+
|
|
599
|
+
const vault = client.vault(await client.machineToken({ scopes: ['vault.lease'] }));
|
|
600
|
+
|
|
601
|
+
try {
|
|
602
|
+
const { accessToken } = await vault.leasePipeToken('github', { userId, purpose: 'list-repos' });
|
|
603
|
+
// call https://api.github.com/user/repos with it, then drop it
|
|
604
|
+
} catch (e) {
|
|
605
|
+
if (e instanceof PipeNotConnectedError || e instanceof PipeReauthorizationRequiredError) {
|
|
606
|
+
return redirect(e.connectUrlWith({ clientId, returnTo: 'https://app.acme.com/settings' }));
|
|
607
|
+
}
|
|
608
|
+
if (e instanceof PipeTemporarilyUnavailableError) return retryLater(e.retryAfter);
|
|
609
|
+
throw e; // PipeLeaseDeniedError: the app is not granted this pipe
|
|
610
|
+
}
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
With a token issued for the person (they signed in to your app), leave `userId` out. To
|
|
614
|
+
send somebody to connect before any lease, `client.pipeConnectUrl('github', returnTo)` is
|
|
615
|
+
the hosted connect page, preselected to your app; they come back with
|
|
616
|
+
`?provider=github&status=connected` (or `cancelled`, `failed`).
|
|
617
|
+
|
|
529
618
|
## Roles & permissions (federated RBAC)
|
|
530
619
|
|
|
531
620
|
Declare your app's authorization **roles** and **permissions** in code, then push them
|
|
@@ -600,6 +689,228 @@ the manifest to `{issuer}/api/v1/apps/manifest`. It is a server-side operation
|
|
|
600
689
|
your `clientSecret` off the browser. The wire format matches the PHP SDK
|
|
601
690
|
(`cboxdk/laravel-id-client`), so any SDK can publish the same catalog.
|
|
602
691
|
|
|
692
|
+
## Management API
|
|
693
|
+
|
|
694
|
+
`@cboxdk/id-js/management` is a typed client for Cbox ID's management planes. It is
|
|
695
|
+
generated from the OpenAPI documents the server publishes, so every method, path, scope and
|
|
696
|
+
type matches the server you generated it from. Use it from server code only: every client
|
|
697
|
+
holds a management credential.
|
|
698
|
+
|
|
699
|
+
| Client | Plane | Credential | `baseUrl` |
|
|
700
|
+
| --- | --- | --- | --- |
|
|
701
|
+
| `EnvironmentClient` | One environment's tenancy: organizations, users, apps, roles, SSO, audit logs… | `cbid_env_…` key, or a delegated access token | The environment's own host, or the platform root with `environment` |
|
|
702
|
+
| `WorkspaceClient` | The workspace above its environments: projects, environments, team, keys | `cbid_ws_…` key, or a person's root token | `https://api.cboxid.com` (default) |
|
|
703
|
+
| `PlatformClient` | The deployment itself, for operators | Delegated operator token only | `https://api.cboxid.com` (default) |
|
|
704
|
+
| `AccountClient` | A person's own account | Delegated token only | The environment's own host |
|
|
705
|
+
|
|
706
|
+
Method names are the server's action names: `apps.secrets.rotate` is
|
|
707
|
+
`env.apps.secrets.rotate(...)`, and `sso.connections.require_sso` is
|
|
708
|
+
`env.sso.connections.requireSso(...)`. Path parameters come first, in path order, then the body
|
|
709
|
+
(or the query, for a read), then per-call options. Every call resolves to
|
|
710
|
+
`{ data, meta, body, status, replayed, idempotencyKey, requestId, headers }`.
|
|
711
|
+
|
|
712
|
+
### Create an app and rotate its secret
|
|
713
|
+
|
|
714
|
+
```ts
|
|
715
|
+
import { EnvironmentClient, CboxIdApiError, ApprovalDeniedError } from '@cboxdk/id-js/management';
|
|
716
|
+
|
|
717
|
+
const env = new EnvironmentClient({
|
|
718
|
+
baseUrl: 'https://acme.cboxid.com',
|
|
719
|
+
apiKey: process.env.CBOX_ID_ENV_KEY!, // cbid_env_…
|
|
720
|
+
onApprovalRequired: (approval, { action }) => {
|
|
721
|
+
console.log(`Approve ${action} on your device. Code: ${approval.binding_code}`);
|
|
722
|
+
},
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
const { data: app } = await env.apps.create({
|
|
726
|
+
name: 'Billing',
|
|
727
|
+
type: 'web',
|
|
728
|
+
redirect_uris: ['https://billing.acme.com/callback'],
|
|
729
|
+
});
|
|
730
|
+
// app.client_secret is in this response and in no other. Store it now.
|
|
731
|
+
|
|
732
|
+
try {
|
|
733
|
+
const { data: secret } = await env.apps.secrets.rotate(app.id, { grace_seconds: 3600 });
|
|
734
|
+
// secret.client_secret: the new secret, shown once.
|
|
735
|
+
} catch (e) {
|
|
736
|
+
if (e instanceof ApprovalDeniedError) console.log('Rotation was declined.');
|
|
737
|
+
else if (e instanceof CboxIdApiError && e.isValidationError) console.log(e.errors);
|
|
738
|
+
else throw e;
|
|
739
|
+
}
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
When a key's policy holds an action for a person's approval, the server answers
|
|
743
|
+
`202 approval_required`. By default the client calls `onApprovalRequired` (show the
|
|
744
|
+
`binding_code` so the person can match it on their device), polls the approval, and repeats the
|
|
745
|
+
request with `Cbox-Approval: <id>` and the same `Idempotency-Key` once it is approved.
|
|
746
|
+
`ApprovalDeniedError` and `ApprovalExpiredError` are thrown when it is denied or expires. If you
|
|
747
|
+
do not want to wait in the same call, pass `{ approval: 'return' }`:
|
|
748
|
+
|
|
749
|
+
```ts
|
|
750
|
+
const outcome = await env.apps.secrets.rotate(app.id, { grace_seconds: 0 }, { approval: 'return' });
|
|
751
|
+
if (outcome.pending) {
|
|
752
|
+
console.log(`Code: ${outcome.approval.binding_code}`);
|
|
753
|
+
const { data } = await outcome.resume(); // polls, then repeats the request
|
|
754
|
+
}
|
|
755
|
+
```
|
|
756
|
+
|
|
757
|
+
### Bootstrap an environment from a workspace key
|
|
758
|
+
|
|
759
|
+
```ts
|
|
760
|
+
import { WorkspaceClient, EnvironmentClient } from '@cboxdk/id-js/management';
|
|
761
|
+
|
|
762
|
+
const workspace = new WorkspaceClient({ apiKey: process.env.CBOX_ID_WS_KEY! }); // cbid_ws_…
|
|
763
|
+
|
|
764
|
+
const { data: created } = await workspace.environments.create({
|
|
765
|
+
name: 'Staging',
|
|
766
|
+
type: 'sandbox',
|
|
767
|
+
initial_key: { name: 'bootstrap', scopes: ['apps:write', 'organizations:write'] },
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
// The first management key, returned once. On an idempotent replay it is null.
|
|
771
|
+
const env = new EnvironmentClient({ baseUrl: created!.issuer!, apiKey: created!.initial_key!.token! });
|
|
772
|
+
await env.organizations.create({ name: 'Acme', slug: 'acme' });
|
|
773
|
+
```
|
|
774
|
+
|
|
775
|
+
### Idempotency, retries and errors
|
|
776
|
+
|
|
777
|
+
- Every `POST`, `PUT`, `PATCH` and `DELETE` sends an `Idempotency-Key`, a fresh UUID unless you
|
|
778
|
+
pass `{ idempotencyKey }`. Network failures, `5xx`, `429` and `409 idempotency_in_progress` are
|
|
779
|
+
retried with the **same** key (`retry: { maxRetries, baseDelayMs, maxDelayMs }`; default 3
|
|
780
|
+
retries). `Retry-After` is respected; one longer than `maxDelayMs` is thrown instead of waited
|
|
781
|
+
out. `replayed` is `true` when the server returned the first request's stored answer
|
|
782
|
+
(`Idempotent-Replayed`). A secret in a replayed answer is `null`.
|
|
783
|
+
- A failed call throws `CboxIdApiError` with `status`, `error` (the stable code), `message`,
|
|
784
|
+
`errors` (field-keyed, on `validation_failed`), `requestId` (the envelope's `request_id`,
|
|
785
|
+
else the `X-Request-Id` header; quote it when reporting a problem) and `retryAfter`. A call that never
|
|
786
|
+
got an answer throws `ManagementNetworkError`, which carries the `idempotencyKey` so you can
|
|
787
|
+
repeat the request safely.
|
|
788
|
+
- The client never logs. Secrets in responses are returned as they arrive, and request bodies
|
|
789
|
+
are never put in an error.
|
|
790
|
+
|
|
791
|
+
### One token for every environment
|
|
792
|
+
|
|
793
|
+
A person's access token issued at the platform root reaches the workspace, account and
|
|
794
|
+
operator planes there, and any environment of their workspace when the request names it with
|
|
795
|
+
`Cbox-Environment`. Pass `environment` (an id or slug) and the root host as `baseUrl`:
|
|
796
|
+
|
|
797
|
+
```ts
|
|
798
|
+
const staging = new EnvironmentClient({
|
|
799
|
+
baseUrl: 'https://api.cboxid.com',
|
|
800
|
+
accessToken: () => tokens.current(), // the person's root token
|
|
801
|
+
environment: 'acme-staging', // sent as Cbox-Environment on every request
|
|
802
|
+
});
|
|
803
|
+
await staging.organizations.portalLinks.create(orgId, { intents: ['sso', 'dsync'] });
|
|
804
|
+
```
|
|
805
|
+
|
|
806
|
+
What the token may do there is bounded by the person's role and the token's scopes. A
|
|
807
|
+
`cbid_env_…` key is bound to its own environment's host, so `environment` is refused with one.
|
|
808
|
+
|
|
809
|
+
### Lists
|
|
810
|
+
|
|
811
|
+
Every paged list also has an `…All` variant that iterates every item, fetching pages as you
|
|
812
|
+
reach them. It follows `meta.next_cursor` on the environment plane and `meta.next_page` on the
|
|
813
|
+
workspace plane:
|
|
814
|
+
|
|
815
|
+
```ts
|
|
816
|
+
for await (const org of env.organizations.listAll({ status: 'active' })) {
|
|
817
|
+
console.log(org.id, org.name);
|
|
818
|
+
}
|
|
819
|
+
```
|
|
820
|
+
|
|
821
|
+
### Fine-grained authorization
|
|
822
|
+
|
|
823
|
+
`env.fga.*` writes relationship tuples and asks checks. Every write answers with a
|
|
824
|
+
`consistency_token`; pass it to a check that must see the write:
|
|
825
|
+
|
|
826
|
+
```ts
|
|
827
|
+
import { fgaTuple } from '@cboxdk/id-js/management';
|
|
828
|
+
|
|
829
|
+
const { data: written } = await env.fga.tuples.write({
|
|
830
|
+
tuples: [{ resource_type: 'document', resource_id: 'leave', relation: 'viewer', subject: { type: 'user', id: 'alice' } }],
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
const { data } = await env.fga.check({
|
|
834
|
+
resource_type: 'document', resource_id: 'leave', relation: 'viewer',
|
|
835
|
+
subject_type: 'user', subject_id: 'alice',
|
|
836
|
+
consistency_token: written.consistency_token,
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
// Up to 100 at once, in the tuple notation, answered in order:
|
|
840
|
+
await env.fga.checkBatch({ checks: ['document:leave#viewer@user:alice', 'document:readme#editor@user:alice'] });
|
|
841
|
+
```
|
|
842
|
+
|
|
843
|
+
`env.fga.tuples.delete()`, `env.fga.resources.list()` (which documents can alice view),
|
|
844
|
+
`env.fga.subjects.list()` (who can view this one), and `env.fga.schema.get()` /
|
|
845
|
+
`.update()` / `.validate()` complete it. `fgaTuple()` writes the notation from the same
|
|
846
|
+
object `tuples.write` takes.
|
|
847
|
+
|
|
848
|
+
### Audit Logs
|
|
849
|
+
|
|
850
|
+
Your app records what its users did, per organization (your customer), and Cbox ID keeps
|
|
851
|
+
each organization's events in a tamper-evident hash chain. `AuditLogger` buffers events and
|
|
852
|
+
sends them in batches of up to 100, each batch under its own `Idempotency-Key`. It sends a
|
|
853
|
+
batch when it is full, every `flushIntervalMs` (default 5 s), and on `flush()` / `close()`. A
|
|
854
|
+
batch that fails stays queued with the same key, so sending it again never records an event
|
|
855
|
+
twice:
|
|
856
|
+
|
|
857
|
+
```ts
|
|
858
|
+
import { AuditLogger, exportAuditLogs, verifyAuditLogChain } from '@cboxdk/id-js/management';
|
|
859
|
+
|
|
860
|
+
const audit = new AuditLogger(env, { onError: (e) => console.error('audit flush failed', e) });
|
|
861
|
+
|
|
862
|
+
audit.record({
|
|
863
|
+
organization_id: org.id,
|
|
864
|
+
action: 'invoice.voided',
|
|
865
|
+
actor: { id: user.id, type: 'user', name: user.name },
|
|
866
|
+
targets: [{ id: invoice.id, type: 'invoice' }],
|
|
867
|
+
context: { location: request.ip, user_agent: request.headers['user-agent'] },
|
|
868
|
+
metadata: { reason: 'duplicate' },
|
|
869
|
+
}); // occurred_at defaults to now
|
|
870
|
+
|
|
871
|
+
await audit.close(); // on shutdown: stop the timer and send what is left
|
|
872
|
+
```
|
|
873
|
+
|
|
874
|
+
Read events with `env.auditLogs.events.listAll({ organization_id, actions, range_start })`.
|
|
875
|
+
To get a CSV, `exportAuditLogs(env, filters)` starts an export and polls it until it is ready.
|
|
876
|
+
Its `url` is signed and short-lived. To check the chain yourself instead of trusting the
|
|
877
|
+
server's `env.auditLogs.verify()`, call `verifyAuditLogChain(env, { organization_id })` or
|
|
878
|
+
`verifyAuditChain(events)`. They recompute `sha256(prev_hash + canonical JSON)` exactly as
|
|
879
|
+
the server does: keys sorted at every depth, and slashes and Unicode unescaped. They report
|
|
880
|
+
`{ valid, reason, broken_at_sequence, … }`. Keep floats out of metadata you want to verify:
|
|
881
|
+
`1.0` and `1` arrive as the same JavaScript number.
|
|
882
|
+
|
|
883
|
+
### Delegated tokens and DPoP
|
|
884
|
+
|
|
885
|
+
Pass `accessToken` (a string, or a function that is called before every request so it can
|
|
886
|
+
refresh) instead of `apiKey`. For a DPoP-bound token, pass a signer built from the key pair the
|
|
887
|
+
token was bound to:
|
|
888
|
+
|
|
889
|
+
```ts
|
|
890
|
+
import { AccountClient, createDPoPSigner } from '@cboxdk/id-js/management';
|
|
891
|
+
|
|
892
|
+
const me = new AccountClient({
|
|
893
|
+
baseUrl: 'https://acme.cboxid.com',
|
|
894
|
+
accessToken: () => tokens.current(),
|
|
895
|
+
dpop: await createDPoPSigner(keyPair), // ES256, WebCrypto
|
|
896
|
+
});
|
|
897
|
+
await me.sessions.revokeOthers();
|
|
898
|
+
```
|
|
899
|
+
|
|
900
|
+
### Types, metadata and regenerating
|
|
901
|
+
|
|
902
|
+
Schema and operation types are exported per plane as namespaces (`EnvironmentApi.App`,
|
|
903
|
+
`WorkspaceApi.EnvironmentsCreateBody`). The operation tables (`environmentOperations`, …) list
|
|
904
|
+
each action's method, path, scope, danger and whether it can be held for approval, which is
|
|
905
|
+
useful for showing a confirmation before a `critical` action. `env.request(method, path, input)`
|
|
906
|
+
calls a route that is not generated.
|
|
907
|
+
|
|
908
|
+
The specs are vendored in `openapi/`. `npm run generate` (Node 22.6 or later) regenerates
|
|
909
|
+
`src/management/generated/` from them, and
|
|
910
|
+
`npm run generate -- --fetch environment=https://acme.cboxid.com` (or `workspace=`, `platform=`,
|
|
911
|
+
`account=`, `all=`) refreshes a vendored spec from a running server first. The test suite fails
|
|
912
|
+
when the generated code and the vendored specs disagree.
|
|
913
|
+
|
|
603
914
|
## Security & scope
|
|
604
915
|
|
|
605
916
|
Login is hardened by default — PKCE, `state`, nonce, and full `id_token` verification
|
|
@@ -607,8 +918,9 @@ Login is hardened by default — PKCE, `state`, nonce, and full `id_token` verif
|
|
|
607
918
|
freshness window. Keep `clientSecret` and webhook secrets server-side.
|
|
608
919
|
|
|
609
920
|
This is a **client**. It authenticates users and calls a Cbox ID instance's standard
|
|
610
|
-
endpoints
|
|
611
|
-
|
|
921
|
+
endpoints. The management planes (organizations, apps, SSO, keys…) are reached through
|
|
922
|
+
[`@cboxdk/id-js/management`](#management-api), which only calls the instance's published API;
|
|
923
|
+
the capabilities themselves live in [`cboxdk/laravel-id`](https://github.com/cboxdk/laravel-id).
|
|
612
924
|
|
|
613
925
|
Report vulnerabilities via this repo's GitHub **Private Vulnerability Reporting**.
|
|
614
926
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { assertSecureIssuer } from './chunk-QQW63PGU.js';
|
|
2
|
+
import { ConfigurationError, AuthenticationError, oauthError } from './chunk-SEUD4TE4.js';
|
|
2
3
|
|
|
3
4
|
// src/api-keys.ts
|
|
4
5
|
var MAX_API_KEY_CACHE_TTL_MS = 6e4;
|
|
@@ -150,5 +151,5 @@ async function digest(key) {
|
|
|
150
151
|
}
|
|
151
152
|
|
|
152
153
|
export { ApiKeyVerifier, MAX_API_KEY_CACHE_TTL_MS };
|
|
153
|
-
//# sourceMappingURL=chunk-
|
|
154
|
-
//# sourceMappingURL=chunk-
|
|
154
|
+
//# sourceMappingURL=chunk-2E25IPBA.js.map
|
|
155
|
+
//# sourceMappingURL=chunk-2E25IPBA.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/api-keys.ts"],"names":[],"mappings":"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","file":"chunk-2E25IPBA.js","sourcesContent":["import type { OrganizationRole } from './claims.js';\nimport { AuthenticationError, ConfigurationError, oauthError } from './errors.js';\nimport { assertSecureIssuer } from './issuer.js';\n\n/**\n * A customer API key Cbox ID vouches for: live, bound to YOUR app, and held by an active\n * member of `org`.\n *\n * The field names are the endpoint's own, so the claim helpers read it like a token —\n * `hasPermission(answer, 'invoices:create')`, `organization(answer)?.role`. (A type\n * alias rather than an interface for that reason: only an alias is assignable to the\n * helpers' record type.)\n */\nexport type ActiveApiKey = {\n active: true;\n /** The key's id — log this, never the key. */\n key_id: string;\n /** The person who holds the key (their subject id). */\n sub: string;\n /** The organization the key acts in. */\n org: string;\n /** The holder's membership tier in `org`; null for a tier this SDK does not know. */\n org_role: OrganizationRole | null;\n /**\n * What the key may do, already re-capped by Cbox ID: the key's own list intersected\n * with what the holder holds for your app right now. A demoted holder's key loses the\n * permission on the next verification, not at the next reissue.\n */\n permissions: string[];\n /** Your app's client id — always, or the answer is refused. */\n client_id: string;\n /** ISO 8601 UTC expiry, or null for a key that does not expire. */\n expires_at: string | null;\n};\n\n/**\n * Every refusal: unknown, malformed, revoked, expired, another app's key, a holder who\n * left. Cbox ID never says which, so the endpoint cannot be used to probe keys — and\n * neither does this.\n */\nexport interface InactiveApiKey {\n active: false;\n}\n\n/** What {@link ApiKeyVerifier.verifyApiKey} returns. Narrow on `active`. */\nexport type ApiKeyVerification = ActiveApiKey | InactiveApiKey;\n\n/** Configuration for an {@link ApiKeyVerifier}. */\nexport interface ApiKeyVerifierConfig {\n /** Base URL (issuer) of the Cbox ID instance. */\n issuer: string;\n /** Your app's OAuth client id — the app the keys were issued for. */\n clientId: string;\n /** Your app's client secret. Verification is a confidential-client call. */\n clientSecret: string;\n /** Timeout (ms) for the verification request. Defaults to `10000`. */\n timeoutMs?: number;\n /**\n * Cache an ACTIVE answer for this long (ms), so a burst of requests with one key costs\n * one round-trip. Off (`0`) by default and at most {@link MAX_API_KEY_CACHE_TTL_MS}:\n * while an answer is cached, a revoked key keeps working, and a demoted holder keeps\n * the permissions they lost. Never cached past the key's `expires_at`. Refusals are\n * never cached, so a key issued a moment ago works on its first request.\n */\n cacheTtlMs?: number;\n}\n\n/** The longest {@link ApiKeyVerifierConfig.cacheTtlMs} allowed: one minute. */\nexport const MAX_API_KEY_CACHE_TTL_MS = 60_000;\n\n/** Enough for a busy API's working set; oldest entries go first beyond it. */\nconst MAX_CACHE_ENTRIES = 1_000;\n\nconst ORGANIZATION_ROLES: ReadonlySet<string> = new Set<OrganizationRole>([\n 'owner',\n 'admin',\n 'developer',\n 'member',\n 'viewer',\n]);\n\n/**\n * Verifies customer API keys presented to your API: `POST {issuer}/oauth/api-keys/verify`,\n * authenticated with your app's own client credentials.\n *\n * SERVER-SIDE ONLY. It holds your client secret, so it lives in the\n * `@cboxdk/id-js/server` entry and the Next.js adapter, never in the main entry that a\n * browser bundle pulls in — and it refuses to be constructed in a browser at all.\n *\n * ```ts\n * import { ApiKeyVerifier } from '@cboxdk/id-js/server';\n * import { hasPermission } from '@cboxdk/id-js';\n *\n * const keys = new ApiKeyVerifier({ issuer, clientId, clientSecret, cacheTtlMs: 10_000 });\n *\n * const answer = await keys.verifyApiKey(request.headers.get('x-api-key') ?? '');\n * if (!answer.active) return unauthorized();\n * if (!hasPermission(answer, 'invoices:create')) return forbidden();\n * ```\n */\nexport class ApiKeyVerifier {\n private readonly endpoint: string;\n private readonly timeoutMs: number;\n private readonly cacheTtlMs: number;\n private readonly cache = new Map<string, { answer: ActiveApiKey; until: number }>();\n\n constructor(private readonly config: ApiKeyVerifierConfig) {\n // A heuristic, and enough of one: a page has a `document`, and no server runtime\n // this SDK supports (Node, Deno, Bun, edge workers) defines one.\n if (typeof (globalThis as { document?: unknown }).document !== 'undefined') {\n throw new ConfigurationError(\n 'API key verification uses your client secret and must run on your server, never in a browser.',\n );\n }\n if (!config.issuer || !config.clientId || !config.clientSecret) {\n throw new ConfigurationError('Verifying API keys needs issuer, clientId and clientSecret.');\n }\n assertSecureIssuer(config.issuer);\n\n const cacheTtlMs = config.cacheTtlMs ?? 0;\n if (!Number.isFinite(cacheTtlMs) || cacheTtlMs < 0 || cacheTtlMs > MAX_API_KEY_CACHE_TTL_MS) {\n throw new ConfigurationError(\n `cacheTtlMs must be between 0 and ${MAX_API_KEY_CACHE_TTL_MS}: a cached answer keeps a revoked key working for that long.`,\n );\n }\n\n this.endpoint = `${config.issuer.replace(/\\/$/, '')}/oauth/api-keys/verify`;\n this.timeoutMs = config.timeoutMs ?? 10_000;\n this.cacheTtlMs = cacheTtlMs;\n }\n\n /**\n * Ask Cbox ID whether `key` is good for this app, and what it may do.\n *\n * Resolves `{ active: false }` for every kind of bad key, without a request for an empty\n * one. Throws `AuthenticationError` when the call itself fails (wrong client\n * credentials, an unreachable instance) or when the answer cannot be trusted — one that\n * names another app's `client_id`, or does not have the promised shape. Both mean \"do\n * not let this request in\"; the difference is that a throw is something to look at.\n */\n async verifyApiKey(key: string): Promise<ApiKeyVerification> {\n if (typeof key !== 'string' || key.trim() === '') {\n return { active: false };\n }\n\n const cacheKey = this.cacheTtlMs > 0 ? await digest(key) : null;\n if (cacheKey !== null) {\n const hit = this.cache.get(cacheKey);\n if (hit && hit.until > Date.now()) {\n return hit.answer;\n }\n this.cache.delete(cacheKey);\n }\n\n const answer = this.parse(await this.request(key));\n\n if (cacheKey !== null && answer.active) {\n this.remember(cacheKey, answer);\n }\n\n return answer;\n }\n\n private async request(key: string): Promise<unknown> {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), this.timeoutMs);\n\n let response: Response;\n try {\n response = await fetch(this.endpoint, {\n method: 'POST',\n headers: {\n authorization: `Basic ${btoa(`${this.config.clientId}:${this.config.clientSecret}`)}`,\n accept: 'application/json',\n 'content-type': 'application/x-www-form-urlencoded',\n },\n body: new URLSearchParams({ key }),\n signal: controller.signal,\n });\n } catch (error) {\n throw new AuthenticationError(`API key verification request failed: ${String(error)}`);\n } finally {\n clearTimeout(timer);\n }\n\n if (!response.ok) {\n throw await oauthError(response, 'API key verification failed');\n }\n\n return response.json().catch(() => null);\n }\n\n private parse(body: unknown): ApiKeyVerification {\n if (typeof body !== 'object' || body === null) {\n throw new AuthenticationError('API key verification returned a body that is not a JSON object.');\n }\n\n const record = body as Record<string, unknown>;\n\n // Only a literal `true` lets anybody in.\n if (record['active'] !== true) {\n return { active: false };\n }\n\n const keyId = record['key_id'];\n const sub = record['sub'];\n const org = record['org'];\n const clientId = record['client_id'];\n const expiresAt = record['expires_at'];\n const permissions = record['permissions'];\n\n if (\n !nonEmpty(keyId) ||\n !nonEmpty(sub) ||\n !nonEmpty(org) ||\n !nonEmpty(clientId) ||\n !Array.isArray(permissions) ||\n (expiresAt !== null && expiresAt !== undefined && typeof expiresAt !== 'string')\n ) {\n throw new AuthenticationError('API key verification returned an active answer without the promised fields.');\n }\n\n // THE BINDING, CHECKED AGAIN HERE. Cbox ID only ever finds a key bound to the calling\n // client, so this cannot fire against a correct instance — which is exactly when a\n // check that it holds is worth having: a proxy, a misrouted base URL, or a shared\n // secret between two apps would otherwise let one app's customers into another's API.\n if (clientId !== this.config.clientId) {\n throw new AuthenticationError(\n `API key verification answered for client ${clientId}, not this app (${this.config.clientId}); refusing it.`,\n );\n }\n\n // Past its expiry by our clock is expired, whatever the instance thought a moment ago.\n if (typeof expiresAt === 'string') {\n const expiry = Date.parse(expiresAt);\n if (Number.isNaN(expiry) || expiry <= Date.now()) {\n return { active: false };\n }\n }\n\n const role = record['org_role'];\n\n return {\n active: true,\n key_id: keyId,\n sub,\n org,\n org_role: typeof role === 'string' && ORGANIZATION_ROLES.has(role) ? (role as OrganizationRole) : null,\n permissions: permissions.filter((value): value is string => nonEmpty(value)),\n client_id: clientId,\n expires_at: typeof expiresAt === 'string' ? expiresAt : null,\n };\n }\n\n private remember(cacheKey: string, answer: ActiveApiKey): void {\n const expiry = answer.expires_at === null ? Number.POSITIVE_INFINITY : Date.parse(answer.expires_at);\n const until = Math.min(Date.now() + this.cacheTtlMs, expiry);\n\n if (this.cache.size >= MAX_CACHE_ENTRIES) {\n const oldest = this.cache.keys().next();\n if (!oldest.done) {\n this.cache.delete(oldest.value);\n }\n }\n\n this.cache.set(cacheKey, { answer, until });\n }\n}\n\nfunction nonEmpty(value: unknown): value is string {\n return typeof value === 'string' && value !== '';\n}\n\n/** The cache is keyed by a hash, so live keys are not sitting in memory as plaintext. */\nasync function digest(key: string): Promise<string> {\n const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(key));\n return Array.from(new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, '0')).join('');\n}\n"]}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { ConfigurationError } from './chunk-SEUD4TE4.js';
|
|
2
|
+
|
|
3
|
+
// src/issuer.ts
|
|
4
|
+
function assertSecureIssuer(issuer) {
|
|
5
|
+
let url;
|
|
6
|
+
try {
|
|
7
|
+
url = new URL(issuer);
|
|
8
|
+
} catch {
|
|
9
|
+
throw new ConfigurationError("Cbox ID config `issuer` is not a valid URL.");
|
|
10
|
+
}
|
|
11
|
+
if (url.protocol === "https:") {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1";
|
|
15
|
+
if (url.protocol === "http:" && loopback) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
throw new ConfigurationError(
|
|
19
|
+
`Cbox ID config \`issuer\` must be https (got ${url.protocol}//${url.hostname}).`
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export { assertSecureIssuer };
|
|
24
|
+
//# sourceMappingURL=chunk-QQW63PGU.js.map
|
|
25
|
+
//# sourceMappingURL=chunk-QQW63PGU.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/issuer.ts"],"names":[],"mappings":";;;AAaO,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-QQW63PGU.js","sourcesContent":["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"]}
|
|
@@ -56,26 +56,6 @@ var FrontendApiError = class extends CboxIdError {
|
|
|
56
56
|
retryAfter;
|
|
57
57
|
};
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
59
|
+
export { AuthenticationError, CboxIdError, ConfigurationError, FrontendApiError, InvalidStateError, oauthError };
|
|
60
|
+
//# sourceMappingURL=chunk-SEUD4TE4.js.map
|
|
61
|
+
//# sourceMappingURL=chunk-SEUD4TE4.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/errors.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","file":"chunk-SEUD4TE4.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"]}
|