@cboxdk/id-js 0.15.1 → 0.18.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 +455 -4
- package/dist/chunk-2E25IPBA.js +155 -0
- package/dist/chunk-2E25IPBA.js.map +1 -0
- package/dist/{chunk-3V6OKRWF.js → chunk-CXGKOJRJ.js} +374 -90
- package/dist/chunk-CXGKOJRJ.js.map +1 -0
- package/dist/chunk-QQW63PGU.js +25 -0
- package/dist/chunk-QQW63PGU.js.map +1 -0
- package/dist/chunk-SEUD4TE4.js +61 -0
- package/dist/chunk-SEUD4TE4.js.map +1 -0
- package/dist/{client-CecDIu_N.d.cts → client-BSZASrMo.d.ts} +109 -136
- package/dist/{client-CecDIu_N.d.ts → client-BxUHm5hX.d.cts} +109 -136
- package/dist/errors-D61xljZE.d.cts +87 -0
- package/dist/errors-D61xljZE.d.ts +87 -0
- package/dist/index.cjs +440 -35
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +40 -90
- package/dist/index.d.ts +40 -90
- package/dist/index.js +64 -25
- package/dist/index.js.map +1 -1
- package/dist/management.cjs +4372 -0
- package/dist/management.cjs.map +1 -0
- package/dist/management.d.cts +10923 -0
- package/dist/management.d.ts +10923 -0
- package/dist/management.js +4335 -0
- package/dist/management.js.map +1 -0
- package/dist/nextjs.cjs +517 -42
- package/dist/nextjs.cjs.map +1 -1
- package/dist/nextjs.d.cts +29 -8
- package/dist/nextjs.d.ts +29 -8
- package/dist/nextjs.js +42 -12
- package/dist/nextjs.js.map +1 -1
- package/dist/server.cjs +220 -0
- package/dist/server.cjs.map +1 -0
- package/dist/server.d.cts +105 -0
- package/dist/server.d.ts +105 -0
- package/dist/server.js +5 -0
- package/dist/server.js.map +1 -0
- package/dist/types-BYZldock.d.cts +316 -0
- package/dist/types-BYZldock.d.ts +316 -0
- package/package.json +14 -2
- package/dist/chunk-3V6OKRWF.js.map +0 -1
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.
|
|
@@ -304,6 +506,26 @@ const ok = await verifyWebhook({
|
|
|
304
506
|
});
|
|
305
507
|
```
|
|
306
508
|
|
|
509
|
+
### Standard Webhooks
|
|
510
|
+
|
|
511
|
+
An endpoint registered with `signature_scheme: 'standard_webhooks'` (or switched with
|
|
512
|
+
`env.webhooks.signatureScheme.change(id, { signature_scheme: 'standard_webhooks' })`) is
|
|
513
|
+
signed the [Standard Webhooks](https://www.standardwebhooks.com/) way: `webhook-id`,
|
|
514
|
+
`webhook-timestamp` and `webhook-signature`. Verify it with `verifyStandardWebhook`:
|
|
515
|
+
|
|
516
|
+
```ts
|
|
517
|
+
import { verifyStandardWebhook } from '@cboxdk/id-js';
|
|
518
|
+
|
|
519
|
+
const ok = await verifyStandardWebhook({
|
|
520
|
+
payload: rawBody, // the exact bytes received
|
|
521
|
+
headers: request.headers, // a Headers object or a plain record
|
|
522
|
+
secret: process.env.CBOX_ID_WEBHOOK_SECRET!, // whsec_…, or the endpoint's old 64-hex secret
|
|
523
|
+
});
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
It accepts any valid `v1` signature among several (sent while a secret rotates), uses the same
|
|
527
|
+
5-minute freshness window, and never throws.
|
|
528
|
+
|
|
307
529
|
## Token Vault
|
|
308
530
|
|
|
309
531
|
Broker downstream credentials (API keys for OpenAI, GitHub, …) through the instance's
|
|
@@ -334,8 +556,8 @@ claims for you to enforce. Requires the app's client to hold the `apps.manifest`
|
|
|
334
556
|
```ts
|
|
335
557
|
import { defineAuthz, publishManifest } from '@cboxdk/id-js';
|
|
336
558
|
|
|
337
|
-
// Declare the catalog (validated: keys are `feature:action
|
|
338
|
-
// declared permissions). Keep this next to the code that enforces it.
|
|
559
|
+
// Declare the catalog (validated: keys are lowercase `feature:action` slugs, and roles
|
|
560
|
+
// must reference declared permissions). Keep this next to the code that enforces it.
|
|
339
561
|
export const authz = defineAuthz({
|
|
340
562
|
permissions: [
|
|
341
563
|
{ key: 'invoices:create', description: 'Create invoices' },
|
|
@@ -360,11 +582,239 @@ const summary = await publishManifest(
|
|
|
360
582
|
// → { unchanged, roles_declared, permissions_declared, ... }
|
|
361
583
|
```
|
|
362
584
|
|
|
585
|
+
### Staff roles and self-serve permissions
|
|
586
|
+
|
|
587
|
+
Two flags decide who may hand something out:
|
|
588
|
+
|
|
589
|
+
- **`tenantAssignable: false` on a role** makes it a **staff role** — for your own support
|
|
590
|
+
or operations people, held environment-wide across every customer. Cbox ID never lists
|
|
591
|
+
or accepts it on an organization's own admin pages; only an environment administrator
|
|
592
|
+
can grant it. Roles default to `true`.
|
|
593
|
+
- **`tenantAssignable: true` on a permission** lets an organization's administrators put it
|
|
594
|
+
in custom roles they build themselves. Permissions default to `false`: internal unless
|
|
595
|
+
you opt in.
|
|
596
|
+
|
|
597
|
+
```ts
|
|
598
|
+
export const authz = defineAuthz({
|
|
599
|
+
permissions: [
|
|
600
|
+
{ key: 'parcels:read', description: 'View parcels', tenantAssignable: true },
|
|
601
|
+
// Lets a staff member start a support session in this app (see "Support sessions").
|
|
602
|
+
{ key: 'support:impersonate', description: 'Act as a customer' },
|
|
603
|
+
],
|
|
604
|
+
roles: [
|
|
605
|
+
{ key: 'viewer', name: 'Viewer', permissions: ['parcels:read'] },
|
|
606
|
+
{ key: 'support', name: 'Support', description: 'Our support team',
|
|
607
|
+
permissions: ['support:impersonate', 'parcels:read'], tenantAssignable: false },
|
|
608
|
+
],
|
|
609
|
+
});
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
On the wire the flags are `tenant_assignable`, and only their non-default state is sent
|
|
613
|
+
(`false` on a role, `true` on a permission). Both are part of the manifest's `version`, so
|
|
614
|
+
marking a role staff-only in a new deploy re-syncs it rather than being skipped as
|
|
615
|
+
unchanged. A value that is not a real boolean — the string `"false"` from a YAML file, say
|
|
616
|
+
— is refused, because it would otherwise read as the opposite of what it says.
|
|
617
|
+
|
|
363
618
|
`publishManifest` mints a client-credentials token (`scope=apps.manifest`) and POSTs
|
|
364
619
|
the manifest to `{issuer}/api/v1/apps/manifest`. It is a server-side operation — keep
|
|
365
620
|
your `clientSecret` off the browser. The wire format matches the PHP SDK
|
|
366
621
|
(`cboxdk/laravel-id-client`), so any SDK can publish the same catalog.
|
|
367
622
|
|
|
623
|
+
## Management API
|
|
624
|
+
|
|
625
|
+
`@cboxdk/id-js/management` is a typed client for Cbox ID's management planes. It is
|
|
626
|
+
generated from the OpenAPI documents the server publishes, so every method, path, scope and
|
|
627
|
+
type matches the server you generated it from. Use it from server code only: every client
|
|
628
|
+
holds a management credential.
|
|
629
|
+
|
|
630
|
+
| Client | Plane | Credential | `baseUrl` |
|
|
631
|
+
| --- | --- | --- | --- |
|
|
632
|
+
| `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` |
|
|
633
|
+
| `WorkspaceClient` | The workspace above its environments: projects, environments, team, keys | `cbid_ws_…` key, or a person's root token | `https://api.cboxid.com` (default) |
|
|
634
|
+
| `PlatformClient` | The deployment itself, for operators | Delegated operator token only | `https://api.cboxid.com` (default) |
|
|
635
|
+
| `AccountClient` | A person's own account | Delegated token only | The environment's own host |
|
|
636
|
+
|
|
637
|
+
Method names are the server's action names: `apps.secrets.rotate` is
|
|
638
|
+
`env.apps.secrets.rotate(...)`, and `sso.connections.require_sso` is
|
|
639
|
+
`env.sso.connections.requireSso(...)`. Path parameters come first, in path order, then the body
|
|
640
|
+
(or the query, for a read), then per-call options. Every call resolves to
|
|
641
|
+
`{ data, meta, body, status, replayed, idempotencyKey, requestId, headers }`.
|
|
642
|
+
|
|
643
|
+
### Create an app and rotate its secret
|
|
644
|
+
|
|
645
|
+
```ts
|
|
646
|
+
import { EnvironmentClient, CboxIdApiError, ApprovalDeniedError } from '@cboxdk/id-js/management';
|
|
647
|
+
|
|
648
|
+
const env = new EnvironmentClient({
|
|
649
|
+
baseUrl: 'https://acme.cboxid.com',
|
|
650
|
+
apiKey: process.env.CBOX_ID_ENV_KEY!, // cbid_env_…
|
|
651
|
+
onApprovalRequired: (approval, { action }) => {
|
|
652
|
+
console.log(`Approve ${action} on your device. Code: ${approval.binding_code}`);
|
|
653
|
+
},
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
const { data: app } = await env.apps.create({
|
|
657
|
+
name: 'Billing',
|
|
658
|
+
type: 'web',
|
|
659
|
+
redirect_uris: ['https://billing.acme.com/callback'],
|
|
660
|
+
});
|
|
661
|
+
// app.client_secret is in this response and in no other. Store it now.
|
|
662
|
+
|
|
663
|
+
try {
|
|
664
|
+
const { data: secret } = await env.apps.secrets.rotate(app.id, { grace_seconds: 3600 });
|
|
665
|
+
// secret.client_secret: the new secret, shown once.
|
|
666
|
+
} catch (e) {
|
|
667
|
+
if (e instanceof ApprovalDeniedError) console.log('Rotation was declined.');
|
|
668
|
+
else if (e instanceof CboxIdApiError && e.isValidationError) console.log(e.errors);
|
|
669
|
+
else throw e;
|
|
670
|
+
}
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
When a key's policy holds an action for a person's approval, the server answers
|
|
674
|
+
`202 approval_required`. By default the client calls `onApprovalRequired` (show the
|
|
675
|
+
`binding_code` so the person can match it on their device), polls the approval, and repeats the
|
|
676
|
+
request with `Cbox-Approval: <id>` and the same `Idempotency-Key` once it is approved.
|
|
677
|
+
`ApprovalDeniedError` and `ApprovalExpiredError` are thrown when it is denied or expires. If you
|
|
678
|
+
do not want to wait in the same call, pass `{ approval: 'return' }`:
|
|
679
|
+
|
|
680
|
+
```ts
|
|
681
|
+
const outcome = await env.apps.secrets.rotate(app.id, { grace_seconds: 0 }, { approval: 'return' });
|
|
682
|
+
if (outcome.pending) {
|
|
683
|
+
console.log(`Code: ${outcome.approval.binding_code}`);
|
|
684
|
+
const { data } = await outcome.resume(); // polls, then repeats the request
|
|
685
|
+
}
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
### Bootstrap an environment from a workspace key
|
|
689
|
+
|
|
690
|
+
```ts
|
|
691
|
+
import { WorkspaceClient, EnvironmentClient } from '@cboxdk/id-js/management';
|
|
692
|
+
|
|
693
|
+
const workspace = new WorkspaceClient({ apiKey: process.env.CBOX_ID_WS_KEY! }); // cbid_ws_…
|
|
694
|
+
|
|
695
|
+
const { data: created } = await workspace.environments.create({
|
|
696
|
+
name: 'Staging',
|
|
697
|
+
type: 'sandbox',
|
|
698
|
+
initial_key: { name: 'bootstrap', scopes: ['apps:write', 'organizations:write'] },
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
// The first management key, returned once. On an idempotent replay it is null.
|
|
702
|
+
const env = new EnvironmentClient({ baseUrl: created!.issuer!, apiKey: created!.initial_key!.token! });
|
|
703
|
+
await env.organizations.create({ name: 'Acme', slug: 'acme' });
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
### Idempotency, retries and errors
|
|
707
|
+
|
|
708
|
+
- Every `POST`, `PUT`, `PATCH` and `DELETE` sends an `Idempotency-Key`, a fresh UUID unless you
|
|
709
|
+
pass `{ idempotencyKey }`. Network failures, `5xx`, `429` and `409 idempotency_in_progress` are
|
|
710
|
+
retried with the **same** key (`retry: { maxRetries, baseDelayMs, maxDelayMs }`; default 3
|
|
711
|
+
retries). `Retry-After` is respected; one longer than `maxDelayMs` is thrown instead of waited
|
|
712
|
+
out. `replayed` is `true` when the server returned the first request's stored answer
|
|
713
|
+
(`Idempotent-Replayed`). A secret in a replayed answer is `null`.
|
|
714
|
+
- A failed call throws `CboxIdApiError` with `status`, `error` (the stable code), `message`,
|
|
715
|
+
`errors` (field-keyed, on `validation_failed`), `requestId` (the envelope's `request_id`,
|
|
716
|
+
else the `X-Request-Id` header; quote it when reporting a problem) and `retryAfter`. A call that never
|
|
717
|
+
got an answer throws `ManagementNetworkError`, which carries the `idempotencyKey` so you can
|
|
718
|
+
repeat the request safely.
|
|
719
|
+
- The client never logs. Secrets in responses are returned as they arrive, and request bodies
|
|
720
|
+
are never put in an error.
|
|
721
|
+
|
|
722
|
+
### One token for every environment
|
|
723
|
+
|
|
724
|
+
A person's access token issued at the platform root reaches the workspace, account and
|
|
725
|
+
operator planes there, and any environment of their workspace when the request names it with
|
|
726
|
+
`Cbox-Environment`. Pass `environment` (an id or slug) and the root host as `baseUrl`:
|
|
727
|
+
|
|
728
|
+
```ts
|
|
729
|
+
const staging = new EnvironmentClient({
|
|
730
|
+
baseUrl: 'https://api.cboxid.com',
|
|
731
|
+
accessToken: () => tokens.current(), // the person's root token
|
|
732
|
+
environment: 'acme-staging', // sent as Cbox-Environment on every request
|
|
733
|
+
});
|
|
734
|
+
await staging.organizations.portalLinks.create(orgId, { intents: ['sso', 'dsync'] });
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
What the token may do there is bounded by the person's role and the token's scopes. A
|
|
738
|
+
`cbid_env_…` key is bound to its own environment's host, so `environment` is refused with one.
|
|
739
|
+
|
|
740
|
+
### Lists
|
|
741
|
+
|
|
742
|
+
Every paged list also has an `…All` variant that iterates every item, fetching pages as you
|
|
743
|
+
reach them. It follows `meta.next_cursor` on the environment plane and `meta.next_page` on the
|
|
744
|
+
workspace plane:
|
|
745
|
+
|
|
746
|
+
```ts
|
|
747
|
+
for await (const org of env.organizations.listAll({ status: 'active' })) {
|
|
748
|
+
console.log(org.id, org.name);
|
|
749
|
+
}
|
|
750
|
+
```
|
|
751
|
+
|
|
752
|
+
### Audit Logs
|
|
753
|
+
|
|
754
|
+
Your app records what its users did, per organization (your customer), and Cbox ID keeps
|
|
755
|
+
each organization's events in a tamper-evident hash chain. `AuditLogger` buffers events and
|
|
756
|
+
sends them in batches of up to 100, each batch under its own `Idempotency-Key`. It sends a
|
|
757
|
+
batch when it is full, every `flushIntervalMs` (default 5 s), and on `flush()` / `close()`. A
|
|
758
|
+
batch that fails stays queued with the same key, so sending it again never records an event
|
|
759
|
+
twice:
|
|
760
|
+
|
|
761
|
+
```ts
|
|
762
|
+
import { AuditLogger, exportAuditLogs, verifyAuditLogChain } from '@cboxdk/id-js/management';
|
|
763
|
+
|
|
764
|
+
const audit = new AuditLogger(env, { onError: (e) => console.error('audit flush failed', e) });
|
|
765
|
+
|
|
766
|
+
audit.record({
|
|
767
|
+
organization_id: org.id,
|
|
768
|
+
action: 'invoice.voided',
|
|
769
|
+
actor: { id: user.id, type: 'user', name: user.name },
|
|
770
|
+
targets: [{ id: invoice.id, type: 'invoice' }],
|
|
771
|
+
context: { location: request.ip, user_agent: request.headers['user-agent'] },
|
|
772
|
+
metadata: { reason: 'duplicate' },
|
|
773
|
+
}); // occurred_at defaults to now
|
|
774
|
+
|
|
775
|
+
await audit.close(); // on shutdown: stop the timer and send what is left
|
|
776
|
+
```
|
|
777
|
+
|
|
778
|
+
Read events with `env.auditLogs.events.listAll({ organization_id, actions, range_start })`.
|
|
779
|
+
To get a CSV, `exportAuditLogs(env, filters)` starts an export and polls it until it is ready.
|
|
780
|
+
Its `url` is signed and short-lived. To check the chain yourself instead of trusting the
|
|
781
|
+
server's `env.auditLogs.verify()`, call `verifyAuditLogChain(env, { organization_id })` or
|
|
782
|
+
`verifyAuditChain(events)`. They recompute `sha256(prev_hash + canonical JSON)` exactly as
|
|
783
|
+
the server does: keys sorted at every depth, and slashes and Unicode unescaped. They report
|
|
784
|
+
`{ valid, reason, broken_at_sequence, … }`. Keep floats out of metadata you want to verify:
|
|
785
|
+
`1.0` and `1` arrive as the same JavaScript number.
|
|
786
|
+
|
|
787
|
+
### Delegated tokens and DPoP
|
|
788
|
+
|
|
789
|
+
Pass `accessToken` (a string, or a function that is called before every request so it can
|
|
790
|
+
refresh) instead of `apiKey`. For a DPoP-bound token, pass a signer built from the key pair the
|
|
791
|
+
token was bound to:
|
|
792
|
+
|
|
793
|
+
```ts
|
|
794
|
+
import { AccountClient, createDPoPSigner } from '@cboxdk/id-js/management';
|
|
795
|
+
|
|
796
|
+
const me = new AccountClient({
|
|
797
|
+
baseUrl: 'https://acme.cboxid.com',
|
|
798
|
+
accessToken: () => tokens.current(),
|
|
799
|
+
dpop: await createDPoPSigner(keyPair), // ES256, WebCrypto
|
|
800
|
+
});
|
|
801
|
+
await me.sessions.revokeOthers();
|
|
802
|
+
```
|
|
803
|
+
|
|
804
|
+
### Types, metadata and regenerating
|
|
805
|
+
|
|
806
|
+
Schema and operation types are exported per plane as namespaces (`EnvironmentApi.App`,
|
|
807
|
+
`WorkspaceApi.EnvironmentsCreateBody`). The operation tables (`environmentOperations`, …) list
|
|
808
|
+
each action's method, path, scope, danger and whether it can be held for approval, which is
|
|
809
|
+
useful for showing a confirmation before a `critical` action. `env.request(method, path, input)`
|
|
810
|
+
calls a route that is not generated.
|
|
811
|
+
|
|
812
|
+
The specs are vendored in `openapi/`. `npm run generate` (Node 22.6 or later) regenerates
|
|
813
|
+
`src/management/generated/` from them, and
|
|
814
|
+
`npm run generate -- --fetch environment=https://acme.cboxid.com` (or `workspace=`, `platform=`,
|
|
815
|
+
`account=`, `all=`) refreshes a vendored spec from a running server first. The test suite fails
|
|
816
|
+
when the generated code and the vendored specs disagree.
|
|
817
|
+
|
|
368
818
|
## Security & scope
|
|
369
819
|
|
|
370
820
|
Login is hardened by default — PKCE, `state`, nonce, and full `id_token` verification
|
|
@@ -372,8 +822,9 @@ Login is hardened by default — PKCE, `state`, nonce, and full `id_token` verif
|
|
|
372
822
|
freshness window. Keep `clientSecret` and webhook secrets server-side.
|
|
373
823
|
|
|
374
824
|
This is a **client**. It authenticates users and calls a Cbox ID instance's standard
|
|
375
|
-
endpoints
|
|
376
|
-
|
|
825
|
+
endpoints. The management planes (organizations, apps, SSO, keys…) are reached through
|
|
826
|
+
[`@cboxdk/id-js/management`](#management-api), which only calls the instance's published API;
|
|
827
|
+
the capabilities themselves live in [`cboxdk/laravel-id`](https://github.com/cboxdk/laravel-id).
|
|
377
828
|
|
|
378
829
|
Report vulnerabilities via this repo's GitHub **Private Vulnerability Reporting**.
|
|
379
830
|
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { assertSecureIssuer } from './chunk-QQW63PGU.js';
|
|
2
|
+
import { ConfigurationError, AuthenticationError, oauthError } from './chunk-SEUD4TE4.js';
|
|
3
|
+
|
|
4
|
+
// src/api-keys.ts
|
|
5
|
+
var MAX_API_KEY_CACHE_TTL_MS = 6e4;
|
|
6
|
+
var MAX_CACHE_ENTRIES = 1e3;
|
|
7
|
+
var ORGANIZATION_ROLES = /* @__PURE__ */ new Set([
|
|
8
|
+
"owner",
|
|
9
|
+
"admin",
|
|
10
|
+
"developer",
|
|
11
|
+
"member",
|
|
12
|
+
"viewer"
|
|
13
|
+
]);
|
|
14
|
+
var ApiKeyVerifier = class {
|
|
15
|
+
constructor(config) {
|
|
16
|
+
this.config = config;
|
|
17
|
+
if (typeof globalThis.document !== "undefined") {
|
|
18
|
+
throw new ConfigurationError(
|
|
19
|
+
"API key verification uses your client secret and must run on your server, never in a browser."
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
if (!config.issuer || !config.clientId || !config.clientSecret) {
|
|
23
|
+
throw new ConfigurationError("Verifying API keys needs issuer, clientId and clientSecret.");
|
|
24
|
+
}
|
|
25
|
+
assertSecureIssuer(config.issuer);
|
|
26
|
+
const cacheTtlMs = config.cacheTtlMs ?? 0;
|
|
27
|
+
if (!Number.isFinite(cacheTtlMs) || cacheTtlMs < 0 || cacheTtlMs > MAX_API_KEY_CACHE_TTL_MS) {
|
|
28
|
+
throw new ConfigurationError(
|
|
29
|
+
`cacheTtlMs must be between 0 and ${MAX_API_KEY_CACHE_TTL_MS}: a cached answer keeps a revoked key working for that long.`
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
this.endpoint = `${config.issuer.replace(/\/$/, "")}/oauth/api-keys/verify`;
|
|
33
|
+
this.timeoutMs = config.timeoutMs ?? 1e4;
|
|
34
|
+
this.cacheTtlMs = cacheTtlMs;
|
|
35
|
+
}
|
|
36
|
+
config;
|
|
37
|
+
endpoint;
|
|
38
|
+
timeoutMs;
|
|
39
|
+
cacheTtlMs;
|
|
40
|
+
cache = /* @__PURE__ */ new Map();
|
|
41
|
+
/**
|
|
42
|
+
* Ask Cbox ID whether `key` is good for this app, and what it may do.
|
|
43
|
+
*
|
|
44
|
+
* Resolves `{ active: false }` for every kind of bad key, without a request for an empty
|
|
45
|
+
* one. Throws `AuthenticationError` when the call itself fails (wrong client
|
|
46
|
+
* credentials, an unreachable instance) or when the answer cannot be trusted — one that
|
|
47
|
+
* names another app's `client_id`, or does not have the promised shape. Both mean "do
|
|
48
|
+
* not let this request in"; the difference is that a throw is something to look at.
|
|
49
|
+
*/
|
|
50
|
+
async verifyApiKey(key) {
|
|
51
|
+
if (typeof key !== "string" || key.trim() === "") {
|
|
52
|
+
return { active: false };
|
|
53
|
+
}
|
|
54
|
+
const cacheKey = this.cacheTtlMs > 0 ? await digest(key) : null;
|
|
55
|
+
if (cacheKey !== null) {
|
|
56
|
+
const hit = this.cache.get(cacheKey);
|
|
57
|
+
if (hit && hit.until > Date.now()) {
|
|
58
|
+
return hit.answer;
|
|
59
|
+
}
|
|
60
|
+
this.cache.delete(cacheKey);
|
|
61
|
+
}
|
|
62
|
+
const answer = this.parse(await this.request(key));
|
|
63
|
+
if (cacheKey !== null && answer.active) {
|
|
64
|
+
this.remember(cacheKey, answer);
|
|
65
|
+
}
|
|
66
|
+
return answer;
|
|
67
|
+
}
|
|
68
|
+
async request(key) {
|
|
69
|
+
const controller = new AbortController();
|
|
70
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
71
|
+
let response;
|
|
72
|
+
try {
|
|
73
|
+
response = await fetch(this.endpoint, {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: {
|
|
76
|
+
authorization: `Basic ${btoa(`${this.config.clientId}:${this.config.clientSecret}`)}`,
|
|
77
|
+
accept: "application/json",
|
|
78
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
79
|
+
},
|
|
80
|
+
body: new URLSearchParams({ key }),
|
|
81
|
+
signal: controller.signal
|
|
82
|
+
});
|
|
83
|
+
} catch (error) {
|
|
84
|
+
throw new AuthenticationError(`API key verification request failed: ${String(error)}`);
|
|
85
|
+
} finally {
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
}
|
|
88
|
+
if (!response.ok) {
|
|
89
|
+
throw await oauthError(response, "API key verification failed");
|
|
90
|
+
}
|
|
91
|
+
return response.json().catch(() => null);
|
|
92
|
+
}
|
|
93
|
+
parse(body) {
|
|
94
|
+
if (typeof body !== "object" || body === null) {
|
|
95
|
+
throw new AuthenticationError("API key verification returned a body that is not a JSON object.");
|
|
96
|
+
}
|
|
97
|
+
const record = body;
|
|
98
|
+
if (record["active"] !== true) {
|
|
99
|
+
return { active: false };
|
|
100
|
+
}
|
|
101
|
+
const keyId = record["key_id"];
|
|
102
|
+
const sub = record["sub"];
|
|
103
|
+
const org = record["org"];
|
|
104
|
+
const clientId = record["client_id"];
|
|
105
|
+
const expiresAt = record["expires_at"];
|
|
106
|
+
const permissions = record["permissions"];
|
|
107
|
+
if (!nonEmpty(keyId) || !nonEmpty(sub) || !nonEmpty(org) || !nonEmpty(clientId) || !Array.isArray(permissions) || expiresAt !== null && expiresAt !== void 0 && typeof expiresAt !== "string") {
|
|
108
|
+
throw new AuthenticationError("API key verification returned an active answer without the promised fields.");
|
|
109
|
+
}
|
|
110
|
+
if (clientId !== this.config.clientId) {
|
|
111
|
+
throw new AuthenticationError(
|
|
112
|
+
`API key verification answered for client ${clientId}, not this app (${this.config.clientId}); refusing it.`
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
if (typeof expiresAt === "string") {
|
|
116
|
+
const expiry = Date.parse(expiresAt);
|
|
117
|
+
if (Number.isNaN(expiry) || expiry <= Date.now()) {
|
|
118
|
+
return { active: false };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const role = record["org_role"];
|
|
122
|
+
return {
|
|
123
|
+
active: true,
|
|
124
|
+
key_id: keyId,
|
|
125
|
+
sub,
|
|
126
|
+
org,
|
|
127
|
+
org_role: typeof role === "string" && ORGANIZATION_ROLES.has(role) ? role : null,
|
|
128
|
+
permissions: permissions.filter((value) => nonEmpty(value)),
|
|
129
|
+
client_id: clientId,
|
|
130
|
+
expires_at: typeof expiresAt === "string" ? expiresAt : null
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
remember(cacheKey, answer) {
|
|
134
|
+
const expiry = answer.expires_at === null ? Number.POSITIVE_INFINITY : Date.parse(answer.expires_at);
|
|
135
|
+
const until = Math.min(Date.now() + this.cacheTtlMs, expiry);
|
|
136
|
+
if (this.cache.size >= MAX_CACHE_ENTRIES) {
|
|
137
|
+
const oldest = this.cache.keys().next();
|
|
138
|
+
if (!oldest.done) {
|
|
139
|
+
this.cache.delete(oldest.value);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
this.cache.set(cacheKey, { answer, until });
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
function nonEmpty(value) {
|
|
146
|
+
return typeof value === "string" && value !== "";
|
|
147
|
+
}
|
|
148
|
+
async function digest(key) {
|
|
149
|
+
const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(key));
|
|
150
|
+
return Array.from(new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export { ApiKeyVerifier, MAX_API_KEY_CACHE_TTL_MS };
|
|
154
|
+
//# sourceMappingURL=chunk-2E25IPBA.js.map
|
|
155
|
+
//# sourceMappingURL=chunk-2E25IPBA.js.map
|