@cboxdk/id-js 0.19.0 → 0.20.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/CHANGELOG.md +185 -0
- package/README.md +24 -3
- package/RELEASE-CANDIDATE.md +11 -0
- package/dist/api-keys-BE3R3q_A.d.ts +105 -0
- package/dist/api-keys-C8-Q4eFA.d.cts +105 -0
- package/dist/chunk-EVZ6RB3K.js +98 -0
- package/dist/chunk-EVZ6RB3K.js.map +1 -0
- package/dist/decisions-C84Fzah4.d.ts +23 -0
- package/dist/decisions-DNd9UXwi.d.cts +23 -0
- package/dist/index.cjs +127 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +24 -4
- package/dist/index.js.map +1 -1
- package/dist/management.cjs +352 -19
- package/dist/management.cjs.map +1 -1
- package/dist/management.d.cts +994 -42
- package/dist/management.d.ts +994 -42
- package/dist/management.js +352 -19
- package/dist/management.js.map +1 -1
- package/dist/nextjs.d.cts +1 -1
- package/dist/nextjs.d.ts +1 -1
- package/dist/server.cjs +103 -9
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +3 -105
- package/dist/server.d.ts +3 -105
- package/dist/server.js +1 -0
- package/package.json +8 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@cboxdk/id-js` are recorded here. Earlier releases are described
|
|
4
|
+
in their [GitHub releases](https://github.com/cboxdk/id-js/releases).
|
|
5
|
+
|
|
6
|
+
## [0.20.0] - 2026-11-07
|
|
7
|
+
|
|
8
|
+
### Upgrade
|
|
9
|
+
|
|
10
|
+
Workspace team.remove requires its body with roles_version. Team writes use roles arrays and current CAS versions. Re-read state on conflicts. Live permission checks bind subject, organization and app; access-token role projections alone do not authorize sensitive actions.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `checkPermissions()` and `PermissionDecision` for current server authorization, bound to the exact subject, organization and app. Decisions are never cached; malformed, redirected, oversized or unavailable responses fail closed.
|
|
15
|
+
- Updated management clients for multiple app roles, independent role and environment-access versions, scoped invitations and nullable member email.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Manifest publication requires a complete acknowledgement for the submitted client, version and checksum, including valid publication counts. Publishing definitions never assigns access.
|
|
20
|
+
- Workspace member removal now requires `roles_version` and removes native ID app roles only. Role edits require `roles` and `roles_version`; environment access uses its separate version.
|
|
21
|
+
|
|
22
|
+
## [0.19.0] - 2026-10-10
|
|
23
|
+
|
|
24
|
+
Needs laravel-id 1.24 / the Cbox ID release with feature flags, fine-grained
|
|
25
|
+
authorization, Pipes and SMS as a second factor. Against an older instance the new claim
|
|
26
|
+
reads as absent and the new endpoints answer 404.
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- Feature flags: `featureFlags(source)` and `hasFeature(source, key)` read the
|
|
31
|
+
`feature_flags` claim from a signed-in user or a verified claim set, and
|
|
32
|
+
`CboxUser.featureFlags` carries it (`null` when the scope was not requested, `[]` when
|
|
33
|
+
nothing is on). `FEATURE_FLAGS_SCOPE` is the scope to request.
|
|
34
|
+
- Management API regenerated from the current specs: `env.featureFlags.*` (including
|
|
35
|
+
`evaluate({ user_id, organization_id })`), `env.fga.*` (check, `checkBatch`, tuples
|
|
36
|
+
write/delete/list, resources and subjects lists, schema get/update/validate, with
|
|
37
|
+
`consistency_token` on every read), `env.pipes.*` (pipe setup, grants, connections),
|
|
38
|
+
`env.radar.*`, `env.signin.sms.*`, the environment sign-in methods (`env.signin.social.*`
|
|
39
|
+
update/enable/disable/inherit/offered, `environment_wide` / `organization_id` on social
|
|
40
|
+
set, the auth policy's method fields), `env.branding.appearance.set()` taking the logo and
|
|
41
|
+
favicon as base64 data URIs, `env.users.mfa.sms.remove()`, the HRIS directory
|
|
42
|
+
routes (`env.directories.*`), and on the account plane `me.mfa.sms.remove()` and
|
|
43
|
+
`me.pipes.disconnect()`. `fgaTuple()` writes a tuple in the notation the batch check takes.
|
|
44
|
+
- Pipes: `VaultClient.leasePipeToken(provider, { userId?, purpose })` leases a fresh token
|
|
45
|
+
for a person's connected account. Refusals are typed: `PipeNotConnectedError` and
|
|
46
|
+
`PipeReauthorizationRequiredError` carry `connectUrl` (and `connectUrlWith({ clientId,
|
|
47
|
+
returnTo })`), `PipeTemporarilyUnavailableError` carries `retryAfter`,
|
|
48
|
+
`PipeLeaseDeniedError` is the 403. `client.pipeConnectUrl(provider, returnTo)` and
|
|
49
|
+
`pipeConnectUrl()` build the hosted connect page.
|
|
50
|
+
- `CboxIdFrontend.config({ organization })`: the sign-in document for one organization (by
|
|
51
|
+
id or slug), whose `social` list is exactly what that organization's hosted page offers.
|
|
52
|
+
Each organization is cached on its own. `FrontendConfig.methods` says whether passkeys and
|
|
53
|
+
magic links are switched on.
|
|
54
|
+
- Embedded sign-in: `mfa_required` lists the `factors` the person can answer with,
|
|
55
|
+
`CboxIdFrontend.sendSmsCode(mfaToken)` texts the code, and `submitSecondFactor()` accepts
|
|
56
|
+
`'sms'`.
|
|
57
|
+
|
|
58
|
+
### Changed
|
|
59
|
+
|
|
60
|
+
- The generator reads an action's own `202 Accepted` body (documented as `oneOf` it and the
|
|
61
|
+
approval body), so `env.directories.sync()` now returns the directory.
|
|
62
|
+
|
|
63
|
+
## [0.18.0] - 2026-10-08
|
|
64
|
+
|
|
65
|
+
### Added
|
|
66
|
+
|
|
67
|
+
- `verifyStandardWebhook()`: verifies deliveries from an endpoint on the `standard_webhooks`
|
|
68
|
+
signature scheme (`webhook-id` / `webhook-timestamp` / `webhook-signature`, any valid `v1`
|
|
69
|
+
signature, 5-minute window). Takes a `whsec_…` secret or an endpoint's 64-hex Cbox secret.
|
|
70
|
+
Tested against the Standard Webhooks specification's vector.
|
|
71
|
+
|
|
72
|
+
- `@cboxdk/id-js/management`: typed clients for Cbox ID's management planes, generated from
|
|
73
|
+
the OpenAPI documents the server publishes. `EnvironmentClient` (an environment's own host,
|
|
74
|
+
`cbid_env_…` key or delegated token), `WorkspaceClient` (`cbid_ws_…` key), `PlatformClient`
|
|
75
|
+
(operator token) and `AccountClient` (a person's own token). Methods are named after the
|
|
76
|
+
server's actions (`env.apps.secrets.rotate(id, body)`, `workspace.environments.create(body)`),
|
|
77
|
+
with typed bodies, queries and responses.
|
|
78
|
+
- Every write sends an `Idempotency-Key` (a fresh UUID unless you pass one) and is retried
|
|
79
|
+
with the same key on network failures, `5xx`, `429` and `409 idempotency_in_progress`,
|
|
80
|
+
respecting `Retry-After`. `replayed` reports `Idempotent-Replayed`.
|
|
81
|
+
- Approvals: a `202 approval_required` calls `onApprovalRequired` (show the binding code),
|
|
82
|
+
polls the approval and repeats the request with `Cbox-Approval` and the same key.
|
|
83
|
+
`ApprovalDeniedError` and `ApprovalExpiredError` when it is not approved;
|
|
84
|
+
`{ approval: 'return' }` hands back the pending approval with a `resume()`.
|
|
85
|
+
- `CboxIdApiError` (`status`, `error`, `message`, `errors`, `requestId` from the envelope's
|
|
86
|
+
`request_id` or `X-Request-Id`, `retryAfter`) and `ManagementNetworkError` (with the
|
|
87
|
+
`idempotencyKey` to repeat safely).
|
|
88
|
+
- `environment` on `EnvironmentClient`: with a person's root access token and the platform
|
|
89
|
+
root as `baseUrl`, it sends `Cbox-Environment` so one token can drive any environment of
|
|
90
|
+
the workspace.
|
|
91
|
+
- Audit Logs helpers: `AuditLogger` (buffers events and sends batches of up to 100, each
|
|
92
|
+
with its own Idempotency-Key, on size, interval and `flush()`), `exportAuditLogs()`
|
|
93
|
+
(creates an export and polls it until ready), and `verifyAuditChain()` /
|
|
94
|
+
`verifyAuditLogChain()` with `canonicalJson()` and `auditEventHash()`, which recompute an
|
|
95
|
+
organization's hash chain exactly as the server does.
|
|
96
|
+
- `…All` async iterators on every paged list (cursor and page-number paging).
|
|
97
|
+
- DPoP-bound access tokens: `createDPoPSigner()` and `generateDPoPKeyPair()` (ES256 on
|
|
98
|
+
WebCrypto, with `DPoP-Nonce` challenge handling).
|
|
99
|
+
- Operation tables (`environmentOperations`, …) with each action's method, path, scope, danger
|
|
100
|
+
(from the spec's `x-scope` / `x-danger`) and whether it can be held for approval.
|
|
101
|
+
- `npm run generate` regenerates the clients from the vendored specs in `openapi/`, and
|
|
102
|
+
`--fetch <plane>=<host>` refreshes a spec from a running server first. The test suite fails
|
|
103
|
+
when the two disagree.
|
|
104
|
+
|
|
105
|
+
## [0.17.0] - 2026-09-24
|
|
106
|
+
|
|
107
|
+
Organization selection and support sessions. Needs a Cbox ID instance that understands the
|
|
108
|
+
`organization` / `organization_hint` authorize parameters and emits `org_role` and `act`
|
|
109
|
+
(laravel-id 1.19). Against an older instance the new fields stay empty, and a switch fails
|
|
110
|
+
at the callback instead of silently landing in the old organization (see below).
|
|
111
|
+
|
|
112
|
+
### Added
|
|
113
|
+
|
|
114
|
+
- `createAuthorizationRequest()` accepts `organization` (bind the sign-in to one
|
|
115
|
+
organization), `organizationHint` (preselect it in the hosted picker), and the prompts
|
|
116
|
+
`select_organization` and `create_organization`.
|
|
117
|
+
- `client.switchOrganization(id)` and, on the Next.js adapter,
|
|
118
|
+
`cboxId.switchOrganization(id)`: a new authorization bound to another organization.
|
|
119
|
+
- The organization a sign-in was bound to is echoed on the `AuthorizationRequest` and
|
|
120
|
+
verified at the callback (`stored.organization`): tokens for any other organization are
|
|
121
|
+
refused. The Next.js adapter carries it in a `cbox_id_organization` cookie, and clears
|
|
122
|
+
that cookie on a plain sign-in so an abandoned switch cannot leak its binding into it.
|
|
123
|
+
- `CboxUser` gains typed `organization` (`{ id, name, role }` from `org`, `org_name`,
|
|
124
|
+
`org_role`), `roles`, `permissions` and `actor` (the RFC 8693 `act` claim).
|
|
125
|
+
- Claim helpers that work on a `CboxUser` or on a claim set you verified yourself:
|
|
126
|
+
`organization()`, `actor()`, `isSupportSession()`, `roles()`, `permissions()`,
|
|
127
|
+
`hasRole()`, `hasPermission()`.
|
|
128
|
+
- `client.apiKeysUrl({ clientId?, returnTo?, organization? })` (and `cboxId.apiKeysUrl()`
|
|
129
|
+
on the Next.js adapter): the hosted page where a person creates and revokes API keys for
|
|
130
|
+
your API, `/account/api-keys`, preselected to this app.
|
|
131
|
+
- `ApiKeyVerifier` in a new server-only entry, `@cboxdk/id-js/server`, and
|
|
132
|
+
`cboxId.verifyApiKey(key)` on the Next.js adapter: verifies a customer API key with
|
|
133
|
+
`POST /oauth/api-keys/verify` (HTTP Basic client auth) and returns the typed answer
|
|
134
|
+
`{ active, key_id, sub, org, org_role, permissions, client_id, expires_at }`. It refuses
|
|
135
|
+
an answer for any other `client_id`, treats a key past `expires_at` as inactive, refuses
|
|
136
|
+
to run in a browser, and has an optional cache of active answers (`cacheTtlMs`, at most
|
|
137
|
+
60 s, never past the key's expiry). Not exported from the main entry.
|
|
138
|
+
- `CboxUser.sessionId` and `sessionId()`: the ID Token's `sid`, the sign-in session a
|
|
139
|
+
back-channel logout token names.
|
|
140
|
+
- Staff roles: `tenantAssignable: false` on a `RoleDefinition` publishes the role as
|
|
141
|
+
staff-only (`tenant_assignable: false`), which Cbox ID never offers or accepts on an
|
|
142
|
+
organization's own admin pages.
|
|
143
|
+
- Self-serve permissions: `tenantAssignable: true` on a `PermissionDefinition` publishes it
|
|
144
|
+
as one an organization's administrators may put in their own custom roles
|
|
145
|
+
(`tenant_assignable: true`). Permissions stay internal by default.
|
|
146
|
+
- Exported wire types `ManifestPermission` and `ManifestRole`.
|
|
147
|
+
- Exported types `AuthorizationPrompt`, `AuthorizationRequestOptions`,
|
|
148
|
+
`OrganizationRole`, `CboxActiveOrganization`, `CboxActor`, `ClaimSource`,
|
|
149
|
+
`CboxOrganization`, and `SignInOptions` from `@cboxdk/id-js/nextjs`.
|
|
150
|
+
|
|
151
|
+
### Fixed
|
|
152
|
+
|
|
153
|
+
- `CboxIdConfig.accountPath` was documented as defaulting to `/settings`, which on Cbox ID
|
|
154
|
+
is the organization's settings page for its administrators. `profileUrl()` has actually
|
|
155
|
+
defaulted to the person's own account area, `/account`, since 0.8.0; the documentation
|
|
156
|
+
now says so. If you set `accountPath: '/settings'` because of it, remove it: members who
|
|
157
|
+
are not organization admins are redirected away from that page and lose `return_to`.
|
|
158
|
+
|
|
159
|
+
### Changed
|
|
160
|
+
|
|
161
|
+
- An error returned to the callback (`?error=access_denied`) now sets
|
|
162
|
+
`AuthenticationError.error` and `.errorDescription`, as the back-channel errors already
|
|
163
|
+
did. A refused organization switch is `access_denied`, and an app answers it by switching
|
|
164
|
+
back, not by signing the person out — which it could not tell apart before.
|
|
165
|
+
- `prompt` is typed as `AuthorizationPrompt | AuthorizationPrompt[]` instead of `string`,
|
|
166
|
+
and `prompt: 'none'` combined with another value is refused (OIDC Core §3.1.2.1).
|
|
167
|
+
**Breaking for TypeScript callers that pass a `string` variable**; narrow it to
|
|
168
|
+
`AuthorizationPrompt`.
|
|
169
|
+
- `organization` together with `prompt: 'select_organization'` or `'create_organization'`,
|
|
170
|
+
or an empty `organization` / `organizationHint`, throws `ConfigurationError` before any
|
|
171
|
+
redirect.
|
|
172
|
+
- The manifest `version` now covers both `tenant_assignable` flags, byte-for-byte as the
|
|
173
|
+
PHP reference hashes them: present only in their non-default state, so a catalog that
|
|
174
|
+
never sets them keeps the version it had. The shared cross-SDK fixture gained the
|
|
175
|
+
`staff_role` and `self_serve_permission` cases.
|
|
176
|
+
- `AuthzManifest.permissions` / `.roles` are typed as the wire shapes (`ManifestPermission`,
|
|
177
|
+
`ManifestRole`, snake_case flags) rather than as the declaration types.
|
|
178
|
+
- `defineAuthz()` / `buildManifest()` now refuse a key that is not a lowercase
|
|
179
|
+
`feature:action` slug, and a `tenantAssignable` that is not a boolean, with a
|
|
180
|
+
`ConfigurationError` — both were refused by the server on push anyway.
|
|
181
|
+
- A role that lists the same permission twice is sent and hashed with it once, as the
|
|
182
|
+
server stores it; the repeat used to give a `version` no server computes.
|
|
183
|
+
- `CboxUser` has five new required fields. Code that builds a `CboxUser` by hand (test
|
|
184
|
+
fixtures) must add `organization: null, roles: [], permissions: [], actor: null,
|
|
185
|
+
sessionId: null`.
|
package/README.md
CHANGED
|
@@ -189,12 +189,14 @@ access token's payload on a resource server:
|
|
|
189
189
|
```ts
|
|
190
190
|
import { organization, hasPermission, hasRole, isSupportSession } from '@cboxdk/id-js';
|
|
191
191
|
|
|
192
|
-
|
|
192
|
+
const showCreateHint = hasPermission(payload, 'invoices:create');
|
|
193
193
|
if (organization(user)?.role === 'owner') showBilling();
|
|
194
194
|
```
|
|
195
195
|
|
|
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
|
+
These are token snapshots for presentation. Protected operations require the fresh
|
|
199
|
+
`checkPermissions` decision described below; hiding a control is not authorization.
|
|
198
200
|
|
|
199
201
|
### Feature flags
|
|
200
202
|
|
|
@@ -619,8 +621,27 @@ the hosted connect page, preselected to your app; they come back with
|
|
|
619
621
|
|
|
620
622
|
Declare your app's authorization **roles** and **permissions** in code, then push them
|
|
621
623
|
to Cbox ID on deploy. Your app owns what a role *means*; Cbox ID owns identity and who
|
|
622
|
-
*holds* each role
|
|
623
|
-
|
|
624
|
+
*holds* each role. Publication requires the client's `apps.manifest` scope and a
|
|
625
|
+
server acknowledgement matching the client, version and full catalog checksum.
|
|
626
|
+
It does not assign roles. Expansions of existing roles await explicit activation.
|
|
627
|
+
|
|
628
|
+
Token role and permission claims are snapshots for presentation. Authorize protected
|
|
629
|
+
server requests and queued work using a fresh decision, then enforce your own row
|
|
630
|
+
ownership/organization rules. Shared organization Admin is not app Admin.
|
|
631
|
+
|
|
632
|
+
```ts
|
|
633
|
+
import { checkPermissions } from '@cboxdk/id-js/server';
|
|
634
|
+
|
|
635
|
+
const decision = await checkPermissions(
|
|
636
|
+
{ issuer, clientId }, accessToken, authenticatedSubject, selectedOrganization,
|
|
637
|
+
['invoices:create'],
|
|
638
|
+
);
|
|
639
|
+
if (!decision.allowed()) throw new Error('Forbidden');
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
Derive the subject from verified authentication and authorize the selected organization;
|
|
643
|
+
do not accept either as an unchecked form value. The check has no positive cache,
|
|
644
|
+
refuses redirects and rejects mismatched, incomplete or unavailable decisions.
|
|
624
645
|
|
|
625
646
|
```ts
|
|
626
647
|
import { defineAuthz, publishManifest } from '@cboxdk/id-js';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# id-js 0.20.0
|
|
2
|
+
|
|
3
|
+
Status: prepared local candidate. No public tag, registry publication or deployment has occurred.
|
|
4
|
+
|
|
5
|
+
Add request-bound live permission decisions and catalog checksums; regenerate app catalog, role-definition activation, invitations and native management APIs.
|
|
6
|
+
|
|
7
|
+
## Upgrade requirements
|
|
8
|
+
|
|
9
|
+
Workspace team.remove requires its body with roles_version. Team writes use roles arrays and current CAS versions. Re-read state on conflicts. Live permission checks bind subject, organization and app; access-token role projections alone do not authorize sensitive actions.
|
|
10
|
+
|
|
11
|
+
Dependency installation against public registries remains contingent on the approved release cascade. Local artifact checks prove the enclosed contents only; they do not claim public indexing.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { O as OrganizationRole } from './types-CC1bz68x.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A customer API key Cbox ID vouches for: live, bound to YOUR app, and held by an active
|
|
5
|
+
* member of `org`.
|
|
6
|
+
*
|
|
7
|
+
* The field names are the endpoint's own, so the claim helpers read it like a token —
|
|
8
|
+
* `hasPermission(answer, 'invoices:create')`, `organization(answer)?.role`. (A type
|
|
9
|
+
* alias rather than an interface for that reason: only an alias is assignable to the
|
|
10
|
+
* helpers' record type.)
|
|
11
|
+
*/
|
|
12
|
+
type ActiveApiKey = {
|
|
13
|
+
active: true;
|
|
14
|
+
/** The key's id — log this, never the key. */
|
|
15
|
+
key_id: string;
|
|
16
|
+
/** The person who holds the key (their subject id). */
|
|
17
|
+
sub: string;
|
|
18
|
+
/** The organization the key acts in. */
|
|
19
|
+
org: string;
|
|
20
|
+
/** The holder's membership tier in `org`; null for a tier this SDK does not know. */
|
|
21
|
+
org_role: OrganizationRole | null;
|
|
22
|
+
/**
|
|
23
|
+
* What the key may do, already re-capped by Cbox ID: the key's own list intersected
|
|
24
|
+
* with what the holder holds for your app right now. A demoted holder's key loses the
|
|
25
|
+
* permission on the next verification, not at the next reissue.
|
|
26
|
+
*/
|
|
27
|
+
permissions: string[];
|
|
28
|
+
/** Your app's client id — always, or the answer is refused. */
|
|
29
|
+
client_id: string;
|
|
30
|
+
/** ISO 8601 UTC expiry, or null for a key that does not expire. */
|
|
31
|
+
expires_at: string | null;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Every refusal: unknown, malformed, revoked, expired, another app's key, a holder who
|
|
35
|
+
* left. Cbox ID never says which, so the endpoint cannot be used to probe keys — and
|
|
36
|
+
* neither does this.
|
|
37
|
+
*/
|
|
38
|
+
interface InactiveApiKey {
|
|
39
|
+
active: false;
|
|
40
|
+
}
|
|
41
|
+
/** What {@link ApiKeyVerifier.verifyApiKey} returns. Narrow on `active`. */
|
|
42
|
+
type ApiKeyVerification = ActiveApiKey | InactiveApiKey;
|
|
43
|
+
/** Configuration for an {@link ApiKeyVerifier}. */
|
|
44
|
+
interface ApiKeyVerifierConfig {
|
|
45
|
+
/** Base URL (issuer) of the Cbox ID instance. */
|
|
46
|
+
issuer: string;
|
|
47
|
+
/** Your app's OAuth client id — the app the keys were issued for. */
|
|
48
|
+
clientId: string;
|
|
49
|
+
/** Your app's client secret. Verification is a confidential-client call. */
|
|
50
|
+
clientSecret: string;
|
|
51
|
+
/** Timeout (ms) for the verification request. Defaults to `10000`. */
|
|
52
|
+
timeoutMs?: number;
|
|
53
|
+
/**
|
|
54
|
+
* Cache an ACTIVE answer for this long (ms), so a burst of requests with one key costs
|
|
55
|
+
* one round-trip. Off (`0`) by default and at most {@link MAX_API_KEY_CACHE_TTL_MS}:
|
|
56
|
+
* while an answer is cached, a revoked key keeps working, and a demoted holder keeps
|
|
57
|
+
* the permissions they lost. Never cached past the key's `expires_at`. Refusals are
|
|
58
|
+
* never cached, so a key issued a moment ago works on its first request.
|
|
59
|
+
*/
|
|
60
|
+
cacheTtlMs?: number;
|
|
61
|
+
}
|
|
62
|
+
/** The longest {@link ApiKeyVerifierConfig.cacheTtlMs} allowed: one minute. */
|
|
63
|
+
declare const MAX_API_KEY_CACHE_TTL_MS = 60000;
|
|
64
|
+
/**
|
|
65
|
+
* Verifies customer API keys presented to your API: `POST {issuer}/oauth/api-keys/verify`,
|
|
66
|
+
* authenticated with your app's own client credentials.
|
|
67
|
+
*
|
|
68
|
+
* SERVER-SIDE ONLY. It holds your client secret, so it lives in the
|
|
69
|
+
* `@cboxdk/id-js/server` entry and the Next.js adapter, never in the main entry that a
|
|
70
|
+
* browser bundle pulls in — and it refuses to be constructed in a browser at all.
|
|
71
|
+
*
|
|
72
|
+
* ```ts
|
|
73
|
+
* import { ApiKeyVerifier } from '@cboxdk/id-js/server';
|
|
74
|
+
* import { hasPermission } from '@cboxdk/id-js';
|
|
75
|
+
*
|
|
76
|
+
* const keys = new ApiKeyVerifier({ issuer, clientId, clientSecret, cacheTtlMs: 10_000 });
|
|
77
|
+
*
|
|
78
|
+
* const answer = await keys.verifyApiKey(request.headers.get('x-api-key') ?? '');
|
|
79
|
+
* if (!answer.active) return unauthorized();
|
|
80
|
+
* if (!hasPermission(answer, 'invoices:create')) return forbidden();
|
|
81
|
+
* ```
|
|
82
|
+
*/
|
|
83
|
+
declare class ApiKeyVerifier {
|
|
84
|
+
private readonly config;
|
|
85
|
+
private readonly endpoint;
|
|
86
|
+
private readonly timeoutMs;
|
|
87
|
+
private readonly cacheTtlMs;
|
|
88
|
+
private readonly cache;
|
|
89
|
+
constructor(config: ApiKeyVerifierConfig);
|
|
90
|
+
/**
|
|
91
|
+
* Ask Cbox ID whether `key` is good for this app, and what it may do.
|
|
92
|
+
*
|
|
93
|
+
* Resolves `{ active: false }` for every kind of bad key, without a request for an empty
|
|
94
|
+
* one. Throws `AuthenticationError` when the call itself fails (wrong client
|
|
95
|
+
* credentials, an unreachable instance) or when the answer cannot be trusted — one that
|
|
96
|
+
* names another app's `client_id`, or does not have the promised shape. Both mean "do
|
|
97
|
+
* not let this request in"; the difference is that a throw is something to look at.
|
|
98
|
+
*/
|
|
99
|
+
verifyApiKey(key: string): Promise<ApiKeyVerification>;
|
|
100
|
+
private request;
|
|
101
|
+
private parse;
|
|
102
|
+
private remember;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export { type ActiveApiKey as A, type InactiveApiKey as I, MAX_API_KEY_CACHE_TTL_MS as M, type ApiKeyVerification as a, ApiKeyVerifier as b, type ApiKeyVerifierConfig as c };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { O as OrganizationRole } from './types-CC1bz68x.cjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A customer API key Cbox ID vouches for: live, bound to YOUR app, and held by an active
|
|
5
|
+
* member of `org`.
|
|
6
|
+
*
|
|
7
|
+
* The field names are the endpoint's own, so the claim helpers read it like a token —
|
|
8
|
+
* `hasPermission(answer, 'invoices:create')`, `organization(answer)?.role`. (A type
|
|
9
|
+
* alias rather than an interface for that reason: only an alias is assignable to the
|
|
10
|
+
* helpers' record type.)
|
|
11
|
+
*/
|
|
12
|
+
type ActiveApiKey = {
|
|
13
|
+
active: true;
|
|
14
|
+
/** The key's id — log this, never the key. */
|
|
15
|
+
key_id: string;
|
|
16
|
+
/** The person who holds the key (their subject id). */
|
|
17
|
+
sub: string;
|
|
18
|
+
/** The organization the key acts in. */
|
|
19
|
+
org: string;
|
|
20
|
+
/** The holder's membership tier in `org`; null for a tier this SDK does not know. */
|
|
21
|
+
org_role: OrganizationRole | null;
|
|
22
|
+
/**
|
|
23
|
+
* What the key may do, already re-capped by Cbox ID: the key's own list intersected
|
|
24
|
+
* with what the holder holds for your app right now. A demoted holder's key loses the
|
|
25
|
+
* permission on the next verification, not at the next reissue.
|
|
26
|
+
*/
|
|
27
|
+
permissions: string[];
|
|
28
|
+
/** Your app's client id — always, or the answer is refused. */
|
|
29
|
+
client_id: string;
|
|
30
|
+
/** ISO 8601 UTC expiry, or null for a key that does not expire. */
|
|
31
|
+
expires_at: string | null;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Every refusal: unknown, malformed, revoked, expired, another app's key, a holder who
|
|
35
|
+
* left. Cbox ID never says which, so the endpoint cannot be used to probe keys — and
|
|
36
|
+
* neither does this.
|
|
37
|
+
*/
|
|
38
|
+
interface InactiveApiKey {
|
|
39
|
+
active: false;
|
|
40
|
+
}
|
|
41
|
+
/** What {@link ApiKeyVerifier.verifyApiKey} returns. Narrow on `active`. */
|
|
42
|
+
type ApiKeyVerification = ActiveApiKey | InactiveApiKey;
|
|
43
|
+
/** Configuration for an {@link ApiKeyVerifier}. */
|
|
44
|
+
interface ApiKeyVerifierConfig {
|
|
45
|
+
/** Base URL (issuer) of the Cbox ID instance. */
|
|
46
|
+
issuer: string;
|
|
47
|
+
/** Your app's OAuth client id — the app the keys were issued for. */
|
|
48
|
+
clientId: string;
|
|
49
|
+
/** Your app's client secret. Verification is a confidential-client call. */
|
|
50
|
+
clientSecret: string;
|
|
51
|
+
/** Timeout (ms) for the verification request. Defaults to `10000`. */
|
|
52
|
+
timeoutMs?: number;
|
|
53
|
+
/**
|
|
54
|
+
* Cache an ACTIVE answer for this long (ms), so a burst of requests with one key costs
|
|
55
|
+
* one round-trip. Off (`0`) by default and at most {@link MAX_API_KEY_CACHE_TTL_MS}:
|
|
56
|
+
* while an answer is cached, a revoked key keeps working, and a demoted holder keeps
|
|
57
|
+
* the permissions they lost. Never cached past the key's `expires_at`. Refusals are
|
|
58
|
+
* never cached, so a key issued a moment ago works on its first request.
|
|
59
|
+
*/
|
|
60
|
+
cacheTtlMs?: number;
|
|
61
|
+
}
|
|
62
|
+
/** The longest {@link ApiKeyVerifierConfig.cacheTtlMs} allowed: one minute. */
|
|
63
|
+
declare const MAX_API_KEY_CACHE_TTL_MS = 60000;
|
|
64
|
+
/**
|
|
65
|
+
* Verifies customer API keys presented to your API: `POST {issuer}/oauth/api-keys/verify`,
|
|
66
|
+
* authenticated with your app's own client credentials.
|
|
67
|
+
*
|
|
68
|
+
* SERVER-SIDE ONLY. It holds your client secret, so it lives in the
|
|
69
|
+
* `@cboxdk/id-js/server` entry and the Next.js adapter, never in the main entry that a
|
|
70
|
+
* browser bundle pulls in — and it refuses to be constructed in a browser at all.
|
|
71
|
+
*
|
|
72
|
+
* ```ts
|
|
73
|
+
* import { ApiKeyVerifier } from '@cboxdk/id-js/server';
|
|
74
|
+
* import { hasPermission } from '@cboxdk/id-js';
|
|
75
|
+
*
|
|
76
|
+
* const keys = new ApiKeyVerifier({ issuer, clientId, clientSecret, cacheTtlMs: 10_000 });
|
|
77
|
+
*
|
|
78
|
+
* const answer = await keys.verifyApiKey(request.headers.get('x-api-key') ?? '');
|
|
79
|
+
* if (!answer.active) return unauthorized();
|
|
80
|
+
* if (!hasPermission(answer, 'invoices:create')) return forbidden();
|
|
81
|
+
* ```
|
|
82
|
+
*/
|
|
83
|
+
declare class ApiKeyVerifier {
|
|
84
|
+
private readonly config;
|
|
85
|
+
private readonly endpoint;
|
|
86
|
+
private readonly timeoutMs;
|
|
87
|
+
private readonly cacheTtlMs;
|
|
88
|
+
private readonly cache;
|
|
89
|
+
constructor(config: ApiKeyVerifierConfig);
|
|
90
|
+
/**
|
|
91
|
+
* Ask Cbox ID whether `key` is good for this app, and what it may do.
|
|
92
|
+
*
|
|
93
|
+
* Resolves `{ active: false }` for every kind of bad key, without a request for an empty
|
|
94
|
+
* one. Throws `AuthenticationError` when the call itself fails (wrong client
|
|
95
|
+
* credentials, an unreachable instance) or when the answer cannot be trusted — one that
|
|
96
|
+
* names another app's `client_id`, or does not have the promised shape. Both mean "do
|
|
97
|
+
* not let this request in"; the difference is that a throw is something to look at.
|
|
98
|
+
*/
|
|
99
|
+
verifyApiKey(key: string): Promise<ApiKeyVerification>;
|
|
100
|
+
private request;
|
|
101
|
+
private parse;
|
|
102
|
+
private remember;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export { type ActiveApiKey as A, type InactiveApiKey as I, MAX_API_KEY_CACHE_TTL_MS as M, type ApiKeyVerification as a, ApiKeyVerifier as b, type ApiKeyVerifierConfig as c };
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { assertSecureIssuer } from './chunk-QQW63PGU.js';
|
|
2
|
+
import { ConfigurationError, AuthenticationError } from './chunk-SEUD4TE4.js';
|
|
3
|
+
|
|
4
|
+
// src/decisions.ts
|
|
5
|
+
var PermissionDecision = class {
|
|
6
|
+
constructor(subject, organization, clientId, organizationRole, organizationActive, permissions) {
|
|
7
|
+
this.subject = subject;
|
|
8
|
+
this.organization = organization;
|
|
9
|
+
this.clientId = clientId;
|
|
10
|
+
this.organizationRole = organizationRole;
|
|
11
|
+
this.organizationActive = organizationActive;
|
|
12
|
+
this.permissions = permissions;
|
|
13
|
+
}
|
|
14
|
+
subject;
|
|
15
|
+
organization;
|
|
16
|
+
clientId;
|
|
17
|
+
organizationRole;
|
|
18
|
+
organizationActive;
|
|
19
|
+
permissions;
|
|
20
|
+
allows(permission) {
|
|
21
|
+
return this.organizationActive && this.permissions[permission] === true;
|
|
22
|
+
}
|
|
23
|
+
allowed() {
|
|
24
|
+
const values = Object.values(this.permissions);
|
|
25
|
+
return this.organizationActive && values.length > 0 && values.every((value) => value === true);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
async function checkPermissions(config, accessToken, subject, organization, permissions) {
|
|
29
|
+
assertSecureIssuer(config.issuer);
|
|
30
|
+
const issuer = new URL(config.issuer);
|
|
31
|
+
if (issuer.username || issuer.password || issuer.search || issuer.hash || !config.clientId || !subject || organization === "" || !accessToken || accessToken.length > 32768 || /[\x00-\x20\x7f]/.test(accessToken) || !permissions.length || permissions.length > 50 || permissions.some((permission) => !permission.trim() || new TextEncoder().encode(permission).length > 255)) {
|
|
32
|
+
throw new ConfigurationError("Permission decision request is not configured or valid.");
|
|
33
|
+
}
|
|
34
|
+
const requested = [...new Set(permissions)];
|
|
35
|
+
try {
|
|
36
|
+
const response = await fetch(`${config.issuer.replace(/\/$/, "")}/oauth/decisions`, {
|
|
37
|
+
method: "POST",
|
|
38
|
+
redirect: "error",
|
|
39
|
+
cache: "no-store",
|
|
40
|
+
signal: AbortSignal.timeout(Math.max(1, config.timeoutMs ?? 1e4)),
|
|
41
|
+
headers: { authorization: `Bearer ${accessToken}`, accept: "application/json", "content-type": "application/json" },
|
|
42
|
+
body: JSON.stringify({ subject, org: organization, permission: requested })
|
|
43
|
+
});
|
|
44
|
+
if (response.status !== 200) {
|
|
45
|
+
await response.body?.cancel();
|
|
46
|
+
throw new AuthenticationError("Permission decision could not be verified.", void 0, void 0, response.status);
|
|
47
|
+
}
|
|
48
|
+
const reader = response.body?.getReader();
|
|
49
|
+
if (!reader) throw new AuthenticationError("Permission decision response is invalid.");
|
|
50
|
+
let size = 0;
|
|
51
|
+
let raw = "";
|
|
52
|
+
const decoder = new TextDecoder();
|
|
53
|
+
try {
|
|
54
|
+
for (; ; ) {
|
|
55
|
+
const { value, done } = await reader.read();
|
|
56
|
+
if (done) break;
|
|
57
|
+
size += value.byteLength;
|
|
58
|
+
if (size > 65536) throw new AuthenticationError("Permission decision response exceeded its limit.");
|
|
59
|
+
raw += decoder.decode(value, { stream: true });
|
|
60
|
+
}
|
|
61
|
+
raw += decoder.decode();
|
|
62
|
+
} finally {
|
|
63
|
+
await reader.cancel();
|
|
64
|
+
reader.releaseLock();
|
|
65
|
+
}
|
|
66
|
+
const data = JSON.parse(raw);
|
|
67
|
+
if (!record(data) || !record(data.subject) || data.mode !== "rbac" || data.subject.type !== "user" || data.subject.id !== subject || data.organization !== organization || data.client_id !== config.clientId || typeof data.organization_active !== "boolean" || typeof data.allowed !== "boolean" || !(data.org_role === null || typeof data.org_role === "string" && ["owner", "admin", "developer", "member", "viewer"].includes(data.org_role)) || !Array.isArray(data.results) || data.results.length !== requested.length) {
|
|
68
|
+
throw new AuthenticationError("Permission decision response binding is invalid.");
|
|
69
|
+
}
|
|
70
|
+
const checks = /* @__PURE__ */ Object.create(null);
|
|
71
|
+
for (const result of data.results) {
|
|
72
|
+
if (!record(result) || typeof result.permission !== "string" || typeof result.allowed !== "boolean" || !requested.includes(result.permission) || Object.hasOwn(checks, result.permission)) {
|
|
73
|
+
throw new AuthenticationError("Permission decision result is invalid.");
|
|
74
|
+
}
|
|
75
|
+
checks[result.permission] = result.allowed;
|
|
76
|
+
}
|
|
77
|
+
const decision = new PermissionDecision(
|
|
78
|
+
subject,
|
|
79
|
+
organization,
|
|
80
|
+
config.clientId,
|
|
81
|
+
data.org_role,
|
|
82
|
+
data.organization_active,
|
|
83
|
+
Object.freeze(checks)
|
|
84
|
+
);
|
|
85
|
+
if (data.allowed !== decision.allowed()) throw new AuthenticationError("Permission decision result is inconsistent.");
|
|
86
|
+
return decision;
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (error instanceof AuthenticationError) throw error;
|
|
89
|
+
throw new AuthenticationError("Permission decision could not be verified.");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function record(value) {
|
|
93
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export { PermissionDecision, checkPermissions };
|
|
97
|
+
//# sourceMappingURL=chunk-EVZ6RB3K.js.map
|
|
98
|
+
//# sourceMappingURL=chunk-EVZ6RB3K.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/decisions.ts"],"names":[],"mappings":";;;;AAWO,IAAM,qBAAN,MAAyB;AAAA,EAC9B,YACW,OAAA,EACA,YAAA,EACA,QAAA,EACA,gBAAA,EACA,oBACA,WAAA,EACT;AANS,IAAA,IAAA,CAAA,OAAA,GAAA,OAAA;AACA,IAAA,IAAA,CAAA,YAAA,GAAA,YAAA;AACA,IAAA,IAAA,CAAA,QAAA,GAAA,QAAA;AACA,IAAA,IAAA,CAAA,gBAAA,GAAA,gBAAA;AACA,IAAA,IAAA,CAAA,kBAAA,GAAA,kBAAA;AACA,IAAA,IAAA,CAAA,WAAA,GAAA,WAAA;AAAA,EACR;AAAA,EANQ,OAAA;AAAA,EACA,YAAA;AAAA,EACA,QAAA;AAAA,EACA,gBAAA;AAAA,EACA,kBAAA;AAAA,EACA,WAAA;AAAA,EAGX,OAAO,UAAA,EAA6B;AAClC,IAAA,OAAO,IAAA,CAAK,kBAAA,IAAsB,IAAA,CAAK,WAAA,CAAY,UAAU,CAAA,KAAM,IAAA;AAAA,EACrE;AAAA,EAEA,OAAA,GAAmB;AACjB,IAAA,MAAM,MAAA,GAAS,MAAA,CAAO,MAAA,CAAO,IAAA,CAAK,WAAW,CAAA;AAC7C,IAAA,OAAO,IAAA,CAAK,sBAAsB,MAAA,CAAO,MAAA,GAAS,KAAK,MAAA,CAAO,KAAA,CAAM,CAAA,KAAA,KAAS,KAAA,KAAU,IAAI,CAAA;AAAA,EAC7F;AACF;AAGA,eAAsB,gBAAA,CACpB,MAAA,EACA,WAAA,EACA,OAAA,EACA,cACA,WAAA,EAC6B;AAC7B,EAAA,kBAAA,CAAmB,OAAO,MAAM,CAAA;AAChC,EAAA,MAAM,MAAA,GAAS,IAAI,GAAA,CAAI,MAAA,CAAO,MAAM,CAAA;AACpC,EAAA,IAAI,MAAA,CAAO,YAAY,MAAA,CAAO,QAAA,IAAY,OAAO,MAAA,IAAU,MAAA,CAAO,QAAQ,CAAC,MAAA,CAAO,YAC7E,CAAC,OAAA,IAAW,iBAAiB,EAAA,IAAM,CAAC,eAAe,WAAA,CAAY,MAAA,GAAS,SACxE,iBAAA,CAAkB,IAAA,CAAK,WAAW,CAAA,IAAK,CAAC,YAAY,MAAA,IAAU,WAAA,CAAY,SAAS,EAAA,IACnF,WAAA,CAAY,KAAK,CAAA,UAAA,KAAc,CAAC,WAAW,IAAA,EAAK,IAAK,IAAI,WAAA,EAAY,CAAE,OAAO,UAAU,CAAA,CAAE,MAAA,GAAS,GAAG,CAAA,EAAG;AAC5G,IAAA,MAAM,IAAI,mBAAmB,yDAAyD,CAAA;AAAA,EACxF;AACA,EAAA,MAAM,YAAY,CAAC,GAAG,IAAI,GAAA,CAAI,WAAW,CAAC,CAAA;AAC1C,EAAA,IAAI;AACF,IAAA,MAAM,QAAA,GAAW,MAAM,KAAA,CAAM,CAAA,EAAG,MAAA,CAAO,OAAO,OAAA,CAAQ,KAAA,EAAO,EAAE,CAAC,CAAA,gBAAA,CAAA,EAAoB;AAAA,MAClF,MAAA,EAAQ,MAAA;AAAA,MAAQ,QAAA,EAAU,OAAA;AAAA,MAAS,KAAA,EAAO,UAAA;AAAA,MAC1C,MAAA,EAAQ,YAAY,OAAA,CAAQ,IAAA,CAAK,IAAI,CAAA,EAAG,MAAA,CAAO,SAAA,IAAa,GAAM,CAAC,CAAA;AAAA,MACnE,OAAA,EAAS,EAAE,aAAA,EAAe,CAAA,OAAA,EAAU,WAAW,CAAA,CAAA,EAAI,MAAA,EAAQ,kBAAA,EAAoB,cAAA,EAAgB,kBAAA,EAAmB;AAAA,MAClH,IAAA,EAAM,KAAK,SAAA,CAAU,EAAE,SAAS,GAAA,EAAK,YAAA,EAAc,UAAA,EAAY,SAAA,EAAW;AAAA,KAC3E,CAAA;AACD,IAAA,IAAI,QAAA,CAAS,WAAW,GAAA,EAAK;AAC3B,MAAA,MAAM,QAAA,CAAS,MAAM,MAAA,EAAO;AAC5B,MAAA,MAAM,IAAI,mBAAA,CAAoB,4CAAA,EAA8C,KAAA,CAAA,EAAW,KAAA,CAAA,EAAW,SAAS,MAAM,CAAA;AAAA,IACnH;AACA,IAAA,MAAM,MAAA,GAAS,QAAA,CAAS,IAAA,EAAM,SAAA,EAAU;AACxC,IAAA,IAAI,CAAC,MAAA,EAAQ,MAAM,IAAI,oBAAoB,0CAA0C,CAAA;AACrF,IAAA,IAAI,IAAA,GAAO,CAAA;AACX,IAAA,IAAI,GAAA,GAAM,EAAA;AACV,IAAA,MAAM,OAAA,GAAU,IAAI,WAAA,EAAY;AAChC,IAAA,IAAI;AACF,MAAA,WAAS;AACP,QAAA,MAAM,EAAE,KAAA,EAAO,IAAA,EAAK,GAAI,MAAM,OAAO,IAAA,EAAK;AAC1C,QAAA,IAAI,IAAA,EAAM;AACV,QAAA,IAAA,IAAQ,KAAA,CAAM,UAAA;AACd,QAAA,IAAI,IAAA,GAAO,KAAA,EAAO,MAAM,IAAI,oBAAoB,kDAAkD,CAAA;AAClG,QAAA,GAAA,IAAO,QAAQ,MAAA,CAAO,KAAA,EAAO,EAAE,MAAA,EAAQ,MAAM,CAAA;AAAA,MAC/C;AACA,MAAA,GAAA,IAAO,QAAQ,MAAA,EAAO;AAAA,IACxB,CAAA,SAAE;AACA,MAAA,MAAM,OAAO,MAAA,EAAO;AACpB,MAAA,MAAA,CAAO,WAAA,EAAY;AAAA,IACrB;AACA,IAAA,MAAM,IAAA,GAAgB,IAAA,CAAK,KAAA,CAAM,GAAG,CAAA;AACpC,IAAA,IAAI,CAAC,MAAA,CAAO,IAAI,CAAA,IAAK,CAAC,MAAA,CAAO,IAAA,CAAK,OAAO,CAAA,IAAK,IAAA,CAAK,IAAA,KAAS,MAAA,IACvD,KAAK,OAAA,CAAQ,IAAA,KAAS,MAAA,IAAU,IAAA,CAAK,OAAA,CAAQ,EAAA,KAAO,OAAA,IACpD,IAAA,CAAK,YAAA,KAAiB,YAAA,IAAgB,IAAA,CAAK,SAAA,KAAc,MAAA,CAAO,QAAA,IAChE,OAAO,IAAA,CAAK,wBAAwB,SAAA,IAAa,OAAO,IAAA,CAAK,OAAA,KAAY,SAAA,IACzE,EAAE,IAAA,CAAK,QAAA,KAAa,IAAA,IAAS,OAAO,IAAA,CAAK,QAAA,KAAa,QAAA,IACpD,CAAC,OAAA,EAAS,OAAA,EAAS,aAAa,QAAA,EAAU,QAAQ,CAAA,CAAE,QAAA,CAAS,IAAA,CAAK,QAAQ,CAAA,CAAA,IAC5E,CAAC,KAAA,CAAM,OAAA,CAAQ,IAAA,CAAK,OAAO,CAAA,IAAK,IAAA,CAAK,OAAA,CAAQ,MAAA,KAAW,UAAU,MAAA,EAAQ;AAC7E,MAAA,MAAM,IAAI,oBAAoB,kDAAkD,CAAA;AAAA,IAClF;AACA,IAAA,MAAM,MAAA,mBAAkC,MAAA,CAAO,MAAA,CAAO,IAAI,CAAA;AAC1D,IAAA,KAAA,MAAW,MAAA,IAAU,KAAK,OAAA,EAAsB;AAC9C,MAAA,IAAI,CAAC,OAAO,MAAM,CAAA,IAAK,OAAO,MAAA,CAAO,UAAA,KAAe,QAAA,IAAY,OAAO,MAAA,CAAO,OAAA,KAAY,aACrF,CAAC,SAAA,CAAU,QAAA,CAAS,MAAA,CAAO,UAAU,CAAA,IAAK,OAAO,MAAA,CAAO,MAAA,EAAQ,MAAA,CAAO,UAAU,CAAA,EAAG;AACvF,QAAA,MAAM,IAAI,oBAAoB,wCAAwC,CAAA;AAAA,MACxE;AACA,MAAA,MAAA,CAAO,MAAA,CAAO,UAAU,CAAA,GAAI,MAAA,CAAO,OAAA;AAAA,IACrC;AACA,IAAA,MAAM,WAAW,IAAI,kBAAA;AAAA,MAAmB,OAAA;AAAA,MAAS,YAAA;AAAA,MAAc,MAAA,CAAO,QAAA;AAAA,MACpE,IAAA,CAAK,QAAA;AAAA,MAAqC,IAAA,CAAK,mBAAA;AAAA,MAAqB,MAAA,CAAO,OAAO,MAAM;AAAA,KAAC;AAC3F,IAAA,IAAI,IAAA,CAAK,YAAY,QAAA,CAAS,OAAA,IAAW,MAAM,IAAI,oBAAoB,6CAA6C,CAAA;AACpH,IAAA,OAAO,QAAA;AAAA,EACT,SAAS,KAAA,EAAO;AACd,IAAA,IAAI,KAAA,YAAiB,qBAAqB,MAAM,KAAA;AAChD,IAAA,MAAM,IAAI,oBAAoB,4CAA4C,CAAA;AAAA,EAC5E;AACF;AAEA,SAAS,OAAO,KAAA,EAAkD;AAChE,EAAA,OAAO,KAAA,KAAU,QAAQ,OAAO,KAAA,KAAU,YAAY,CAAC,KAAA,CAAM,QAAQ,KAAK,CAAA;AAC5E","file":"chunk-EVZ6RB3K.js","sourcesContent":["import { AuthenticationError, ConfigurationError } from './errors.js';\nimport { assertSecureIssuer } from './issuer.js';\nimport type { OrganizationRole } from './claims.js';\n\nexport interface PermissionDecisionConfig {\n issuer: string;\n clientId: string;\n timeoutMs?: number;\n}\n\n/** A current server decision; shared membership tiers are not app grants. */\nexport class PermissionDecision {\n constructor(\n readonly subject: string,\n readonly organization: string | null,\n readonly clientId: string,\n readonly organizationRole: OrganizationRole | null,\n readonly organizationActive: boolean,\n readonly permissions: Readonly<Record<string, boolean>>,\n ) {}\n\n allows(permission: string): boolean {\n return this.organizationActive && this.permissions[permission] === true;\n }\n\n allowed(): boolean {\n const values = Object.values(this.permissions);\n return this.organizationActive && values.length > 0 && values.every(value => value === true);\n }\n}\n\n/** No positive cache: check each request and again when queued work starts. */\nexport async function checkPermissions(\n config: PermissionDecisionConfig,\n accessToken: string,\n subject: string,\n organization: string | null,\n permissions: readonly string[],\n): Promise<PermissionDecision> {\n assertSecureIssuer(config.issuer);\n const issuer = new URL(config.issuer);\n if (issuer.username || issuer.password || issuer.search || issuer.hash || !config.clientId\n || !subject || organization === '' || !accessToken || accessToken.length > 32768\n || /[\\x00-\\x20\\x7f]/.test(accessToken) || !permissions.length || permissions.length > 50\n || permissions.some(permission => !permission.trim() || new TextEncoder().encode(permission).length > 255)) {\n throw new ConfigurationError('Permission decision request is not configured or valid.');\n }\n const requested = [...new Set(permissions)];\n try {\n const response = await fetch(`${config.issuer.replace(/\\/$/, '')}/oauth/decisions`, {\n method: 'POST', redirect: 'error', cache: 'no-store',\n signal: AbortSignal.timeout(Math.max(1, config.timeoutMs ?? 10_000)),\n headers: { authorization: `Bearer ${accessToken}`, accept: 'application/json', 'content-type': 'application/json' },\n body: JSON.stringify({ subject, org: organization, permission: requested }),\n });\n if (response.status !== 200) {\n await response.body?.cancel();\n throw new AuthenticationError('Permission decision could not be verified.', undefined, undefined, response.status);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new AuthenticationError('Permission decision response is invalid.');\n let size = 0;\n let raw = '';\n const decoder = new TextDecoder();\n try {\n for (;;) {\n const { value, done } = await reader.read();\n if (done) break;\n size += value.byteLength;\n if (size > 65536) throw new AuthenticationError('Permission decision response exceeded its limit.');\n raw += decoder.decode(value, { stream: true });\n }\n raw += decoder.decode();\n } finally {\n await reader.cancel();\n reader.releaseLock();\n }\n const data: unknown = JSON.parse(raw);\n if (!record(data) || !record(data.subject) || data.mode !== 'rbac'\n || data.subject.type !== 'user' || data.subject.id !== subject\n || data.organization !== organization || data.client_id !== config.clientId\n || typeof data.organization_active !== 'boolean' || typeof data.allowed !== 'boolean'\n || !(data.org_role === null || (typeof data.org_role === 'string'\n && ['owner', 'admin', 'developer', 'member', 'viewer'].includes(data.org_role)))\n || !Array.isArray(data.results) || data.results.length !== requested.length) {\n throw new AuthenticationError('Permission decision response binding is invalid.');\n }\n const checks: Record<string, boolean> = Object.create(null) as Record<string, boolean>;\n for (const result of data.results as unknown[]) {\n if (!record(result) || typeof result.permission !== 'string' || typeof result.allowed !== 'boolean'\n || !requested.includes(result.permission) || Object.hasOwn(checks, result.permission)) {\n throw new AuthenticationError('Permission decision result is invalid.');\n }\n checks[result.permission] = result.allowed;\n }\n const decision = new PermissionDecision(subject, organization, config.clientId,\n data.org_role as OrganizationRole | null, data.organization_active, Object.freeze(checks));\n if (data.allowed !== decision.allowed()) throw new AuthenticationError('Permission decision result is inconsistent.');\n return decision;\n } catch (error) {\n if (error instanceof AuthenticationError) throw error;\n throw new AuthenticationError('Permission decision could not be verified.');\n }\n}\n\nfunction record(value: unknown): value is Record<string, unknown> {\n return value !== null && typeof value === 'object' && !Array.isArray(value);\n}\n"]}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { O as OrganizationRole } from './types-CC1bz68x.js';
|
|
2
|
+
|
|
3
|
+
interface PermissionDecisionConfig {
|
|
4
|
+
issuer: string;
|
|
5
|
+
clientId: string;
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
}
|
|
8
|
+
/** A current server decision; shared membership tiers are not app grants. */
|
|
9
|
+
declare class PermissionDecision {
|
|
10
|
+
readonly subject: string;
|
|
11
|
+
readonly organization: string | null;
|
|
12
|
+
readonly clientId: string;
|
|
13
|
+
readonly organizationRole: OrganizationRole | null;
|
|
14
|
+
readonly organizationActive: boolean;
|
|
15
|
+
readonly permissions: Readonly<Record<string, boolean>>;
|
|
16
|
+
constructor(subject: string, organization: string | null, clientId: string, organizationRole: OrganizationRole | null, organizationActive: boolean, permissions: Readonly<Record<string, boolean>>);
|
|
17
|
+
allows(permission: string): boolean;
|
|
18
|
+
allowed(): boolean;
|
|
19
|
+
}
|
|
20
|
+
/** No positive cache: check each request and again when queued work starts. */
|
|
21
|
+
declare function checkPermissions(config: PermissionDecisionConfig, accessToken: string, subject: string, organization: string | null, permissions: readonly string[]): Promise<PermissionDecision>;
|
|
22
|
+
|
|
23
|
+
export { PermissionDecision as P, type PermissionDecisionConfig as a, checkPermissions as c };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { O as OrganizationRole } from './types-CC1bz68x.cjs';
|
|
2
|
+
|
|
3
|
+
interface PermissionDecisionConfig {
|
|
4
|
+
issuer: string;
|
|
5
|
+
clientId: string;
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
}
|
|
8
|
+
/** A current server decision; shared membership tiers are not app grants. */
|
|
9
|
+
declare class PermissionDecision {
|
|
10
|
+
readonly subject: string;
|
|
11
|
+
readonly organization: string | null;
|
|
12
|
+
readonly clientId: string;
|
|
13
|
+
readonly organizationRole: OrganizationRole | null;
|
|
14
|
+
readonly organizationActive: boolean;
|
|
15
|
+
readonly permissions: Readonly<Record<string, boolean>>;
|
|
16
|
+
constructor(subject: string, organization: string | null, clientId: string, organizationRole: OrganizationRole | null, organizationActive: boolean, permissions: Readonly<Record<string, boolean>>);
|
|
17
|
+
allows(permission: string): boolean;
|
|
18
|
+
allowed(): boolean;
|
|
19
|
+
}
|
|
20
|
+
/** No positive cache: check each request and again when queued work starts. */
|
|
21
|
+
declare function checkPermissions(config: PermissionDecisionConfig, accessToken: string, subject: string, organization: string | null, permissions: readonly string[]): Promise<PermissionDecision>;
|
|
22
|
+
|
|
23
|
+
export { PermissionDecision as P, type PermissionDecisionConfig as a, checkPermissions as c };
|