@vantageos/cloud-identity 0.2.0 → 0.4.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 +77 -1
- package/LICENSE +105 -16
- package/README.md +279 -155
- package/dist/bearer-validation.d.ts +2 -3
- package/dist/bearer-validation.d.ts.map +1 -1
- package/dist/bearer-validation.js +2 -3
- package/dist/bearer-validation.js.map +1 -1
- package/dist/crypto.d.ts +3 -5
- package/dist/crypto.d.ts.map +1 -1
- package/dist/crypto.js +3 -5
- package/dist/crypto.js.map +1 -1
- package/dist/human-path.d.ts +95 -0
- package/dist/human-path.d.ts.map +1 -0
- package/dist/human-path.js +117 -0
- package/dist/human-path.js.map +1 -0
- package/dist/index.d.ts +8 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -4
- package/dist/index.js.map +1 -1
- package/dist/org-guard.d.ts +95 -0
- package/dist/org-guard.d.ts.map +1 -0
- package/dist/org-guard.js +77 -0
- package/dist/org-guard.js.map +1 -0
- package/dist/scope-filter.d.ts +33 -18
- package/dist/scope-filter.d.ts.map +1 -1
- package/dist/scope-filter.js +44 -19
- package/dist/scope-filter.js.map +1 -1
- package/dist/tenancy-domain.d.ts +4 -8
- package/dist/tenancy-domain.d.ts.map +1 -1
- package/dist/tenancy-domain.js +4 -8
- package/dist/tenancy-domain.js.map +1 -1
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
# @vantageos/cloud-identity
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Building blocks for multi-tenant authorization: decide which tenant a caller
|
|
4
|
+
belongs to, and keep every other tenant's rows out of the response.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
If your service holds data for several customers behind one deployment, you
|
|
7
|
+
answer the same four questions on every request — who is calling, which tenant
|
|
8
|
+
they belong to, which rows they may see, and whether their token is genuine.
|
|
9
|
+
This package answers them once, in plain functions with no framework attached,
|
|
10
|
+
so each service does not re-implement (and re-misimplement) its own version.
|
|
11
|
+
|
|
12
|
+
It depends on no web framework, router or database, and runs anywhere
|
|
13
|
+
JavaScript runs.
|
|
6
14
|
|
|
7
15
|
## Install
|
|
8
16
|
|
|
@@ -10,182 +18,298 @@ Consumed by `vantage-peers-mcp` and `@vantageos/vantage-crm-mcp` — provides th
|
|
|
10
18
|
npm install @vantageos/cloud-identity
|
|
11
19
|
```
|
|
12
20
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
| Module | Symbols | Purpose |
|
|
16
|
-
| --- | --- | --- |
|
|
17
|
-
| `@vantageos/cloud-identity/crypto` | `timingSafeEqual` | Constant-time byte comparison (no branch-timing leak). |
|
|
18
|
-
| `@vantageos/cloud-identity/scope-filter` | `passesScopeFilter`, `scopeFilterList`, `scopeFilterGet` | Row-level visibility filter on `(createdBy, namespace)` using the caller's OAuth scope. |
|
|
19
|
-
| `@vantageos/cloud-identity/bearer-validation` | `validateMasterBearer` | Bearer-token parsing + sha256 constant-time match against a configured master secret. |
|
|
20
|
-
| `@vantageos/cloud-identity/types` | `OAuthCtx`, `ScopeProfile`, `NamespacePrefix`, `FromAllowListEntry`, `ValidateMasterBearerResult` | Public type surface (0.1.0). |
|
|
21
|
-
| `@vantageos/cloud-identity/tenancy-domain` | `Workspace`, `WorkspaceMember`, `WorkspaceRole`, `TenantContext`, `workspaceSchema`, `workspaceMemberSchema`, `workspaceRoleSchema`, `tenantContextSchema`, `ScopeViolationError`, `getEffectiveTenantId`, `decodeUnverifiedBearer` | Domain-tenancy layer (0.2.0) — multi-tenant isolation guard + Zod schemas + bearer DECODER (not auth — see security note). |
|
|
22
|
-
|
|
23
|
-
## 0.1.0 scope
|
|
24
|
-
|
|
25
|
-
This release ships the **transport-agnostic** core:
|
|
26
|
-
|
|
27
|
-
- `crypto.timingSafeEqual`
|
|
28
|
-
- `scope-filter.{passesScopeFilter, scopeFilterList, scopeFilterGet}`
|
|
29
|
-
- `bearer-validation.validateMasterBearer`
|
|
30
|
-
- types
|
|
21
|
+
Node 18 or later. ESM only.
|
|
31
22
|
|
|
32
|
-
##
|
|
23
|
+
## Quick start
|
|
33
24
|
|
|
34
|
-
|
|
25
|
+
```js
|
|
26
|
+
import { requireTenantId, scopeFilterList } from "@vantageos/cloud-identity";
|
|
35
27
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
- `decodeUnverifiedBearer(token)` — DECODE-ONLY helper. ⚠️ NOT authentication. NOT a trust boundary. See [Security: bearer decoding](#security-bearer-decoding) below.
|
|
28
|
+
// 1. Which tenant is this caller in? Throws if the answer is "none".
|
|
29
|
+
const tenantId = requireTenantId({
|
|
30
|
+
kind: "session",
|
|
31
|
+
identity: { userId: "user_123", orgId: "org_abc" },
|
|
32
|
+
});
|
|
42
33
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
- Co-designed in a Theta cycle.
|
|
48
|
-
|
|
49
|
-
## Usage examples
|
|
50
|
-
|
|
51
|
-
### Constant-time byte comparison
|
|
52
|
-
|
|
53
|
-
```ts
|
|
54
|
-
import { timingSafeEqual } from "@vantageos/cloud-identity/crypto";
|
|
55
|
-
|
|
56
|
-
const a = new TextEncoder().encode(presentedHash);
|
|
57
|
-
const b = new TextEncoder().encode(expectedHash);
|
|
58
|
-
if (!(await timingSafeEqual(a, b))) {
|
|
59
|
-
return new Response("forbidden", { status: 403 });
|
|
60
|
-
}
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
### Scope-aware row filtering
|
|
64
|
-
|
|
65
|
-
```ts
|
|
66
|
-
import { scopeFilterList } from "@vantageos/cloud-identity/scope-filter";
|
|
67
|
-
import type { OAuthCtx } from "@vantageos/cloud-identity/types";
|
|
68
|
-
|
|
69
|
-
const ctx: OAuthCtx = {
|
|
70
|
-
fromAllowList: ["alice"],
|
|
71
|
-
namespaceReadPrefixes: ["orchestrator/alpha"],
|
|
34
|
+
// 2. Which of these rows may they see?
|
|
35
|
+
const ctx = {
|
|
36
|
+
fromAllowList: ["reports"],
|
|
37
|
+
namespaceReadPrefixes: ["team/finance"],
|
|
72
38
|
namespaceWritePrefixes: [],
|
|
73
39
|
};
|
|
74
|
-
const visible = scopeFilterList(ctx, rowsFromConvex);
|
|
75
|
-
```
|
|
76
40
|
|
|
77
|
-
|
|
41
|
+
const rows = [
|
|
42
|
+
{ createdBy: "reports", namespace: "team/finance" }, // kept
|
|
43
|
+
{ createdBy: "someone-else", namespace: "team/legal" }, // dropped
|
|
44
|
+
];
|
|
78
45
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const res = await validateMasterBearer(
|
|
83
|
-
request.headers.get("authorization") ?? undefined,
|
|
84
|
-
process.env.BEARER_SECRET_MASTER ?? "",
|
|
85
|
-
);
|
|
86
|
-
if (!res.ok) {
|
|
87
|
-
return new Response(`unauthorized: ${res.error}`, { status: 401 });
|
|
88
|
-
}
|
|
46
|
+
console.log(tenantId); // "org_abc"
|
|
47
|
+
console.log(scopeFilterList(ctx, rows)); // only the first row
|
|
89
48
|
```
|
|
90
49
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
50
|
+
Every symbol is available from the package root. Subpath imports
|
|
51
|
+
(`@vantageos/cloud-identity/scope-filter` and friends) also work if you prefer
|
|
52
|
+
to be explicit about where something comes from.
|
|
94
53
|
|
|
95
|
-
|
|
96
|
-
import { getEffectiveTenantId, ScopeViolationError } from "@vantageos/cloud-identity/tenancy-domain";
|
|
97
|
-
import type { TenantContext } from "@vantageos/cloud-identity/tenancy-domain";
|
|
98
|
-
|
|
99
|
-
// 1. Obtain ctx from your VERIFIED auth middleware. Examples:
|
|
100
|
-
// - Clerk session (clerkClient.verifyToken + claims → TenantContext)
|
|
101
|
-
// - signed-JWT middleware (jose / jsonwebtoken with public key)
|
|
102
|
-
// - Convex tenancy-table lookup against an opaque token
|
|
103
|
-
// Whatever path: ctx.workspaceId MUST come from a SIGNATURE-VERIFIED source.
|
|
104
|
-
const ctx: TenantContext = await verifiedAuthMiddleware(request);
|
|
105
|
-
|
|
106
|
-
// 2. At the top of every tool handler that receives a workspaceId argument:
|
|
107
|
-
try {
|
|
108
|
-
const tenantId = getEffectiveTenantId(ctx, args);
|
|
109
|
-
// tenantId is safe — caller's verified ctx matches the requested workspace
|
|
110
|
-
const rows = await db.list({ workspaceId: tenantId });
|
|
111
|
-
} catch (err) {
|
|
112
|
-
if (err instanceof ScopeViolationError) {
|
|
113
|
-
// Translate to 403 — do NOT expose details to caller
|
|
114
|
-
return new Response("forbidden", { status: 403 });
|
|
115
|
-
}
|
|
116
|
-
throw err;
|
|
117
|
-
}
|
|
118
|
-
```
|
|
54
|
+
## Both caller paths, one contract
|
|
119
55
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
56
|
+
A right is never granted by an absent argument. `requireTenantId` refuses by
|
|
57
|
+
default — a missing session, a missing organization, or a missing
|
|
58
|
+
bearer-resolved workspace all throw; there is no branch that falls through to
|
|
59
|
+
"full access" when tenant information is absent. The same function resolves
|
|
60
|
+
both entry paths a VantagePeers Cloud request can arrive on:
|
|
123
61
|
|
|
124
62
|
```ts
|
|
125
|
-
import {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
63
|
+
import {
|
|
64
|
+
requireTenantId,
|
|
65
|
+
decodeUnverifiedBearer,
|
|
66
|
+
type TenantSource,
|
|
67
|
+
} from "@vantageos/cloud-identity";
|
|
68
|
+
|
|
69
|
+
async function resolveTenant(req: {
|
|
70
|
+
session?: { orgId?: string | null } | null;
|
|
71
|
+
bearerToken?: string;
|
|
72
|
+
}): Promise<string> {
|
|
73
|
+
const source: TenantSource = req.session
|
|
74
|
+
? { kind: "session", identity: req.session }
|
|
75
|
+
: {
|
|
76
|
+
// decodeUnverifiedBearer only decodes the shape — verify the token's
|
|
77
|
+
// signature (or look it up against your own store) before trusting
|
|
78
|
+
// any field on the payload. See "What this package does not do".
|
|
79
|
+
kind: "bearer",
|
|
80
|
+
context: await decodeUnverifiedBearer(req.bearerToken!),
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Throws for BOTH paths if no tenant is attached — never returns a
|
|
84
|
+
// "default" tenant id when one is missing.
|
|
85
|
+
return requireTenantId(source);
|
|
86
|
+
}
|
|
144
87
|
|
|
145
|
-
|
|
146
|
-
|
|
88
|
+
// Human path: a signed-in user with an active organization.
|
|
89
|
+
await resolveTenant({ session: { orgId: "org_abc" } }); // -> "org_abc"
|
|
147
90
|
|
|
148
|
-
//
|
|
149
|
-
|
|
91
|
+
// Machine path: a caller presenting an already-resolved bearer token.
|
|
92
|
+
await resolveTenant({ bearerToken: someBase64Token }); // -> the token's workspaceId
|
|
150
93
|
|
|
151
|
-
//
|
|
152
|
-
|
|
94
|
+
// Either path with nothing attached throws instead of granting access.
|
|
95
|
+
await resolveTenant({ session: null }); // throws Error("Unauthenticated: no session.")
|
|
153
96
|
```
|
|
154
97
|
|
|
155
|
-
##
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
98
|
+
## What each function does
|
|
99
|
+
|
|
100
|
+
**Tenant resolution and membership**
|
|
101
|
+
|
|
102
|
+
- `requireTenantId(source)` — returns the caller's tenant id, or throws if
|
|
103
|
+
there is none. It accepts both entry paths behind one contract:
|
|
104
|
+
`{ kind: "session", identity }` for a signed-in human, and
|
|
105
|
+
`{ kind: "bearer", context }` for a machine caller. A missing session, a
|
|
106
|
+
missing organization and an empty organization id are all refusals, never a
|
|
107
|
+
default.
|
|
108
|
+
- `getEffectiveTenantId(ctx, args)` — resolves which tenant a request should
|
|
109
|
+
act on when a caller may legitimately act on more than one.
|
|
110
|
+
|
|
111
|
+
**Row filtering**
|
|
112
|
+
|
|
113
|
+
- `passesScopeFilter(ctx, row)` — true when this row is visible to this
|
|
114
|
+
caller.
|
|
115
|
+
- `scopeFilterList(ctx, rows)` — the subset of rows the caller may see.
|
|
116
|
+
- `scopeFilterGet(ctx, row)` — the row, or `null` if the caller may not see it.
|
|
117
|
+
- `isMasterScope(ctx)` / `isWildcardScope(ctx)` — true when the caller holds
|
|
118
|
+
unrestricted access. Useful for skipping filtering you know is pointless;
|
|
119
|
+
never as a substitute for it.
|
|
120
|
+
|
|
121
|
+
Visibility is granted two ways: the row's `createdBy` appears in the caller's
|
|
122
|
+
`fromAllowList`, or the row's `namespace` sits under one of the caller's
|
|
123
|
+
`namespaceReadPrefixes`. Prefixes match on a path boundary, so a caller allowed
|
|
124
|
+
`team/finance` does not thereby see `team/finance-archive`.
|
|
125
|
+
|
|
126
|
+
**Token validation**
|
|
127
|
+
|
|
128
|
+
- `validateMasterBearer(header, secret)` — compares an `Authorization: Bearer`
|
|
129
|
+
header against a shared secret in constant time, and reports *why* it failed:
|
|
130
|
+
header absent, header malformed, or value mismatched.
|
|
131
|
+
- `timingSafeEqual(a, b)` — constant-time byte comparison, if you need to build
|
|
132
|
+
your own check.
|
|
133
|
+
|
|
134
|
+
**Shapes**
|
|
135
|
+
|
|
136
|
+
Zod schemas and their inferred TypeScript types for workspaces, members,
|
|
137
|
+
roles and tenant context: `workspaceSchema` (type `Workspace`),
|
|
138
|
+
`workspaceMemberSchema` (type `WorkspaceMember`), `workspaceRoleSchema`
|
|
139
|
+
(type `WorkspaceRole`), `tenantContextSchema` (type `TenantContext`) — plus
|
|
140
|
+
`ScopeViolationError` (payload type `ScopeViolationPayload`), for refusals you
|
|
141
|
+
want to catch by type, and `BearerPayload`, the type returned by
|
|
142
|
+
`decodeUnverifiedBearer`.
|
|
143
|
+
|
|
144
|
+
**Supporting types** (no runtime code — import with `import type`)
|
|
145
|
+
|
|
146
|
+
- `OAuthCtx` — the context object every scope-filter function above requires:
|
|
147
|
+
`{ fromAllowList, namespaceReadPrefixes, namespaceWritePrefixes, scope? }`.
|
|
148
|
+
- `ScopeProfile`, `NamespacePrefix`, `FromAllowListEntry` — the field types
|
|
149
|
+
that make up `OAuthCtx`.
|
|
150
|
+
- `ValidateMasterBearerResult` — the return type of `validateMasterBearer`.
|
|
151
|
+
- `ScopeFilterable` — the minimal row shape (`{ createdBy?, namespace? }`)
|
|
152
|
+
accepted by `passesScopeFilter` / `scopeFilterList` / `scopeFilterGet`.
|
|
153
|
+
- `SessionIdentity` — the human-path shape `requireTenantId` accepts under
|
|
154
|
+
`{ kind: "session", identity }` (see "Both caller paths, one contract").
|
|
155
|
+
- `TenantSource` — the discriminated union `requireTenantId` accepts:
|
|
156
|
+
`{ kind: "session", identity }`, `{ kind: "bearer", context }`, or
|
|
157
|
+
`{ kind: "self-host", tenantId }`.
|
|
158
|
+
- `DeploymentMode` — the named `"cloud" | "self-host"` literal union (0.4.0).
|
|
159
|
+
|
|
160
|
+
**Deployment mode and the human path (0.4.0, additive)**
|
|
161
|
+
|
|
162
|
+
- `DeploymentMode` (`"cloud" | "self-host"`) names the two ways this package
|
|
163
|
+
can be run. `cloud` keeps `requireTenantId`'s existing session/bearer
|
|
164
|
+
behaviour exactly as-is — organization/workspace REQUIRED, fail-closed.
|
|
165
|
+
`self-host` is single-tenant: pass `{ kind: "self-host", tenantId }` to
|
|
166
|
+
`requireTenantId`, and it returns `tenantId` when it is a non-empty string.
|
|
167
|
+
The self-host tenant id must be DECLARED via configuration — if you select
|
|
168
|
+
self-host mode without configuring a tenant id, `requireTenantId` throws
|
|
169
|
+
rather than returning a default. A right is never granted by absence,
|
|
170
|
+
whichever mode you are in.
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
import { requireTenantId } from "@vantageos/cloud-identity";
|
|
174
|
+
|
|
175
|
+
// self-host: the tenant id is configured explicitly, once, at startup.
|
|
176
|
+
requireTenantId({ kind: "self-host", tenantId: "the-one-tenant" });
|
|
177
|
+
// -> "the-one-tenant"
|
|
178
|
+
|
|
179
|
+
requireTenantId({ kind: "self-host", tenantId: undefined });
|
|
180
|
+
// -> throws: "No tenant id configured for self-host mode..."
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
- `normalizeVerifiedHumanSession(session)` — **normalizes, does not
|
|
184
|
+
authenticate.** Maps an already-*verified* (not merely well-shaped),
|
|
185
|
+
framework-agnostic Clerk session object (`{ orgId, userId, orgRole }`) to
|
|
186
|
+
`{ tenant, subject, role }`. This package never imports a Clerk SDK; the
|
|
187
|
+
caller MUST verify the session upstream (e.g. Clerk's server-side `auth()`)
|
|
188
|
+
and pass in the already-verified object — never an unverified,
|
|
189
|
+
client-supplied object such as `req.body`, which would make the caller's
|
|
190
|
+
input the trusted source of tenant/subject/role. Refuses (throws) when the
|
|
191
|
+
session has no organization, no user id, or an org role this package does
|
|
192
|
+
not recognize — never defaults silently; these are shape checks, not a
|
|
193
|
+
verification step.
|
|
194
|
+
- `humanAccountRoleSchema` (type `HumanAccountRole`) — the new
|
|
195
|
+
`"owner" | "admin" | "member" | "client"` role union returned by
|
|
196
|
+
`normalizeVerifiedHumanSession`. Distinct from `workspaceRoleSchema`
|
|
197
|
+
(`Admin | Editor | Viewer`) — one is an organization-account role, the
|
|
198
|
+
other is workspace membership; they are never conflated.
|
|
199
|
+
- `ClerkSessionLike` — the minimal input shape `normalizeVerifiedHumanSession` accepts:
|
|
200
|
+
`{ orgId?, userId?, orgRole? }`.
|
|
201
|
+
- `ResolvedHumanIdentity` — the `{ tenant, subject, role }` return type of
|
|
202
|
+
`normalizeVerifiedHumanSession`.
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
import { normalizeVerifiedHumanSession } from "@vantageos/cloud-identity";
|
|
206
|
+
|
|
207
|
+
normalizeVerifiedHumanSession({
|
|
208
|
+
orgId: "org_abc",
|
|
209
|
+
userId: "user_123",
|
|
210
|
+
orgRole: "org:admin",
|
|
211
|
+
});
|
|
212
|
+
// -> { tenant: "org_abc", subject: "user_123", role: "admin" }
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## What this package does not do
|
|
216
|
+
|
|
217
|
+
Worth reading before you rely on it.
|
|
218
|
+
|
|
219
|
+
- **`decodeUnverifiedBearer` decodes a token. It does not verify one.** The
|
|
220
|
+
name is literal. It reads the payload out of a bearer token and checks its
|
|
221
|
+
shape; it does not check a signature, so the contents are whatever the caller
|
|
222
|
+
chose to put there. Treating its output as trusted is a vulnerability, not a
|
|
223
|
+
shortcut. Verify the token — a signed JWT, or a lookup against your own store
|
|
224
|
+
— before you believe any field in it.
|
|
225
|
+
- **This is not an authentication system.** There is no user store, no login,
|
|
226
|
+
no session issuance. It takes an identity you have already established and
|
|
227
|
+
tells you what that identity may reach.
|
|
228
|
+
- **It does not talk to your database.** `scopeFilterList` filters rows you
|
|
229
|
+
have already fetched. If fetching them was itself expensive or unsafe, filter
|
|
230
|
+
earlier, in your query.
|
|
231
|
+
|
|
232
|
+
## Upgrading to 0.4.0
|
|
233
|
+
|
|
234
|
+
**This release is purely additive — nothing you already call changes.** Every
|
|
235
|
+
0.3.0 export keeps its exact behaviour; 0.4.0 only adds two primitives, so an
|
|
236
|
+
upgrade from 0.3.0 compiles and runs unchanged until you choose to adopt them.
|
|
237
|
+
|
|
238
|
+
What is new:
|
|
239
|
+
|
|
240
|
+
- **A named deployment mode.** `requireTenantId` gains a `self-host` source
|
|
241
|
+
alongside `session` and `bearer`. `cloud` is unchanged — an org-less
|
|
242
|
+
identity is still refused. `self-host` is single-tenant and returns the
|
|
243
|
+
tenant you **declare**; it throws when none is configured, never inferring
|
|
244
|
+
one from absence.
|
|
245
|
+
|
|
246
|
+
```js
|
|
247
|
+
import { requireTenantId } from "@vantageos/cloud-identity";
|
|
248
|
+
|
|
249
|
+
// cloud (unchanged): refuses when the session has no org
|
|
250
|
+
requireTenantId({ kind: "session", identity });
|
|
251
|
+
// self-host: returns the DECLARED tenant, throws if it is empty
|
|
252
|
+
requireTenantId({ kind: "self-host", tenantId: process.env.TENANT_ID });
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
- **A human-path normalizer.** `normalizeVerifiedHumanSession` maps an
|
|
256
|
+
**already-verified** Clerk-shaped session into `{ tenant, subject, role }`
|
|
257
|
+
(`role` ∈ `owner | admin | member | client`).
|
|
258
|
+
|
|
259
|
+
```js
|
|
260
|
+
import { normalizeVerifiedHumanSession } from "@vantageos/cloud-identity";
|
|
261
|
+
|
|
262
|
+
// session MUST already be verified upstream, e.g. Clerk auth() server-side:
|
|
263
|
+
const { tenant, subject, role } = normalizeVerifiedHumanSession(session);
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
⚠️ It **normalizes, it does not authenticate.** Passing an unverified,
|
|
267
|
+
client-controlled object (a `req.body`, a query payload) makes that object
|
|
268
|
+
the trusted source of tenant, subject and role — a privilege escalation.
|
|
269
|
+
Verify the session upstream first; only pass the object your provider
|
|
270
|
+
already verified. (Same discipline as `decodeUnverifiedBearer`.)
|
|
271
|
+
|
|
272
|
+
Nothing to change on upgrade, nothing removed. Adopt the new primitives when
|
|
273
|
+
you need them.
|
|
274
|
+
|
|
275
|
+
## Upgrading to 0.3.0
|
|
276
|
+
|
|
277
|
+
**This release changes a default, and it breaks compilation on purpose.**
|
|
278
|
+
|
|
279
|
+
Before 0.3.0, calling a scope-filter function without an authorization context
|
|
280
|
+
returned `true` — no context meant full access. A caller who simply forgot to
|
|
281
|
+
pass the context received every row, and no warning. From 0.3.0 the context is
|
|
282
|
+
required: omitting it is a type error, and passing `null` or `undefined`
|
|
283
|
+
throws.
|
|
284
|
+
|
|
285
|
+
The breakage is deliberate, and it is the point. A caller that stops compiling
|
|
286
|
+
is a caller that has been told. A caller that still compiles and quietly
|
|
287
|
+
returns different rows is an incident nobody attributes to the upgrade.
|
|
288
|
+
|
|
289
|
+
What to do:
|
|
290
|
+
|
|
291
|
+
- If the call site has a real authorization context, pass it. This is almost
|
|
292
|
+
always the right fix, and the context is usually already in scope.
|
|
293
|
+
- If the call site genuinely means "unrestricted", pass the exported constant
|
|
294
|
+
`LEGACY_WILDCARD_CTX` by name. It reproduces the old behaviour exactly. It
|
|
295
|
+
has to be written out, because an intentional bypass belongs in a code review
|
|
296
|
+
and an accidental one should not be possible.
|
|
297
|
+
|
|
298
|
+
```js
|
|
299
|
+
import { scopeFilterList, LEGACY_WILDCARD_CTX } from "@vantageos/cloud-identity";
|
|
300
|
+
|
|
301
|
+
scopeFilterList(LEGACY_WILDCARD_CTX, rows); // explicit, unrestricted
|
|
170
302
|
```
|
|
171
303
|
|
|
172
|
-
|
|
304
|
+
`requireTenantId` is also new in 0.3.0. Nothing existing calls it, so it breaks
|
|
305
|
+
nothing.
|
|
173
306
|
|
|
174
|
-
|
|
175
|
-
- a test fixture / harness helper, OR
|
|
176
|
-
- a decoder for a bearer that was ALREADY verified by an upstream signed-JWT or opaque-token middleware (the production trust boundary).
|
|
307
|
+
Version history is in [CHANGELOG.md](./CHANGELOG.md).
|
|
177
308
|
|
|
178
|
-
|
|
179
|
-
- Clerk session verification (planned 0.3.0 D1 ghost identity injection)
|
|
180
|
-
- signed JWT with rotating issuer key (planned 0.3.0)
|
|
181
|
-
- opaque-token lookup against a Convex tenancy table
|
|
182
|
-
|
|
183
|
-
Do **not** publish a service that treats `decodeUnverifiedBearer` as authentication.
|
|
184
|
-
|
|
185
|
-
## Security doctrine
|
|
309
|
+
## License
|
|
186
310
|
|
|
187
|
-
|
|
311
|
+
FSL-1.1-Apache-2.0. See [LICENSE](./LICENSE).
|
|
188
312
|
|
|
189
|
-
##
|
|
313
|
+
## Issues
|
|
190
314
|
|
|
191
|
-
|
|
315
|
+
https://github.com/vantageos-agency/cloud-identity/issues
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Master-token Bearer validation.
|
|
3
3
|
*
|
|
4
|
-
* Extracted from
|
|
5
|
-
*
|
|
6
|
-
* HARDENED per @vantageos/cloud-identity 0.1.0 contract:
|
|
4
|
+
* Extracted from an earlier in-tree master-gate middleware and HARDENED per
|
|
5
|
+
* this package's contract:
|
|
7
6
|
* - SHA-256 hash both the presented token AND the configured master secret,
|
|
8
7
|
* then compare the two digests with `timingSafeEqual` (constant time).
|
|
9
8
|
* - The original middleware did a direct `token !== masterToken` string
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bearer-validation.d.ts","sourceRoot":"","sources":["../src/bearer-validation.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"bearer-validation.d.ts","sourceRoot":"","sources":["../src/bearer-validation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAGH,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,YAAY,CAAC;AAU7D;;;;;;;;;;;GAWG;AACH,wBAAsB,oBAAoB,CACxC,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,YAAY,EAAE,MAAM,GACnB,OAAO,CAAC,0BAA0B,CAAC,CAsBrC"}
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Master-token Bearer validation.
|
|
3
3
|
*
|
|
4
|
-
* Extracted from
|
|
5
|
-
*
|
|
6
|
-
* HARDENED per @vantageos/cloud-identity 0.1.0 contract:
|
|
4
|
+
* Extracted from an earlier in-tree master-gate middleware and HARDENED per
|
|
5
|
+
* this package's contract:
|
|
7
6
|
* - SHA-256 hash both the presented token AND the configured master secret,
|
|
8
7
|
* then compare the two digests with `timingSafeEqual` (constant time).
|
|
9
8
|
* - The original middleware did a direct `token !== masterToken` string
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bearer-validation.js","sourceRoot":"","sources":["../src/bearer-validation.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"bearer-validation.js","sourceRoot":"","sources":["../src/bearer-validation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAG9C,MAAM,mBAAmB,GAAG,SAAS,CAAC;AAEtC,KAAK,UAAU,WAAW,CAAC,KAAa;IACtC,MAAM,IAAI,GAAG,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAC7C,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC;IAC3D,OAAO,IAAI,UAAU,CAAC,MAAM,CAAC,CAAC;AAChC,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,KAAK,UAAU,oBAAoB,CACxC,UAA8B,EAC9B,YAAoB;IAEpB,IAAI,UAAU,KAAK,SAAS,IAAI,UAAU,KAAK,IAAI,IAAI,UAAU,KAAK,EAAE,EAAE,CAAC;QACzE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC;IACzC,CAAC;IACD,IAAI,CAAC,YAAY,IAAI,YAAY,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC/C,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,WAAW,EAAE,CAAC;IAC3C,CAAC;IACD,sEAAsE;IACtE,MAAM,KAAK,GAAG,UAAU,CAAC,WAAW,EAAE,CAAC;IACvC,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,mBAAmB,CAAC,EAAE,CAAC;QAC3C,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,WAAW,EAAE,CAAC;IAC3C,CAAC;IACD,MAAM,KAAK,GAAG,UAAU,CAAC,KAAK,CAAC,mBAAmB,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,CAAC;IAClE,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACvB,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,WAAW,EAAE,CAAC;IAC3C,CAAC;IACD,MAAM,CAAC,WAAW,EAAE,YAAY,CAAC,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC;QACpD,WAAW,CAAC,KAAK,CAAC;QAClB,WAAW,CAAC,YAAY,CAAC;KAC1B,CAAC,CAAC;IACH,MAAM,EAAE,GAAG,MAAM,eAAe,CAAC,WAAW,EAAE,YAAY,CAAC,CAAC;IAC5D,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,UAAU,EAAE,CAAC;AAC9D,CAAC"}
|
package/dist/crypto.d.ts
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Constant-time byte-array comparison.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* the helper is framework-agnostic and reusable by any caller (hash bytes,
|
|
8
|
-
* raw token bytes, HMAC tags, etc.).
|
|
4
|
+
* Takes `(Uint8Array, Uint8Array)` rather than strings so the helper stays
|
|
5
|
+
* framework-agnostic and reusable by any caller: hash bytes, raw token
|
|
6
|
+
* bytes, HMAC tags.
|
|
9
7
|
*
|
|
10
8
|
* Algorithm (identical to source):
|
|
11
9
|
* 1. Length mismatch → still run a dummy Web Crypto HMAC over equal-length
|
package/dist/crypto.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"crypto.d.ts","sourceRoot":"","sources":["../src/crypto.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"crypto.d.ts","sourceRoot":"","sources":["../src/crypto.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,eAAe,CACnC,CAAC,EAAE,UAAU,EACb,CAAC,EAAE,UAAU,GACZ,OAAO,CAAC,OAAO,CAAC,CAoBlB"}
|
package/dist/crypto.js
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Constant-time byte-array comparison.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* the helper is framework-agnostic and reusable by any caller (hash bytes,
|
|
8
|
-
* raw token bytes, HMAC tags, etc.).
|
|
4
|
+
* Takes `(Uint8Array, Uint8Array)` rather than strings so the helper stays
|
|
5
|
+
* framework-agnostic and reusable by any caller: hash bytes, raw token
|
|
6
|
+
* bytes, HMAC tags.
|
|
9
7
|
*
|
|
10
8
|
* Algorithm (identical to source):
|
|
11
9
|
* 1. Length mismatch → still run a dummy Web Crypto HMAC over equal-length
|
package/dist/crypto.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"crypto.js","sourceRoot":"","sources":["../src/crypto.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"crypto.js","sourceRoot":"","sources":["../src/crypto.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CACnC,CAAa,EACb,CAAa;IAEb,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,MAAM,EAAE,CAAC;QAC1B,6EAA6E;QAC7E,MAAM,KAAK,GAAG,IAAI,UAAU,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;QACvC,MAAM,WAAW,GAAG,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QAC3D,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,SAAS,CACxC,KAAK,EACL,WAAsC,EACtC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,EACjC,KAAK,EACL,CAAC,MAAM,CAAC,CACT,CAAC;QACF,MAAM,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,EAAE,IAAI,EAAE,KAAgC,CAAC,CAAC;QACzE,OAAO,KAAK,CAAC;IACf,CAAC;IACD,IAAI,IAAI,GAAG,CAAC,CAAC;IACb,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QAClC,IAAI,IAAI,CAAC,CAAC,CAAC,CAAE,GAAG,CAAC,CAAC,CAAC,CAAE,CAAC;IACxB,CAAC;IACD,OAAO,IAAI,KAAK,CAAC,CAAC;AACpB,CAAC"}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vantageos/cloud-identity 0.4.0 — Human-path resolver (Clerk → { tenant,
|
|
3
|
+
* subject, role }).
|
|
4
|
+
*
|
|
5
|
+
* Additive-only: does not import any Clerk SDK, and does not modify any
|
|
6
|
+
* existing export. The caller resolves a Clerk session (or any session
|
|
7
|
+
* provider with an equivalent shape) *outside* this package and passes in a
|
|
8
|
+
* plain, already-resolved object; this module only normalizes it into the
|
|
9
|
+
* package's contract.
|
|
10
|
+
*
|
|
11
|
+
* `role` is a NEW literal union (`owner | admin | member | client`),
|
|
12
|
+
* distinct from `workspaceRoleSchema` (`Admin | Editor | Viewer`) in
|
|
13
|
+
* `./tenancy-domain.ts` — the two are unrelated vocabularies (workspace
|
|
14
|
+
* membership role vs. organization-account role) and are never conflated.
|
|
15
|
+
*
|
|
16
|
+
* Consistent with the package's "a right is presented, never inferred from
|
|
17
|
+
* an absence" contract: a session with no organization refuses (throws),
|
|
18
|
+
* and an unrecognized/absent Clerk org role refuses (throws) rather than
|
|
19
|
+
* silently defaulting to a role.
|
|
20
|
+
*/
|
|
21
|
+
import { z } from "zod";
|
|
22
|
+
export declare const humanAccountRoleSchema: z.ZodEnum<{
|
|
23
|
+
owner: "owner";
|
|
24
|
+
admin: "admin";
|
|
25
|
+
member: "member";
|
|
26
|
+
client: "client";
|
|
27
|
+
}>;
|
|
28
|
+
export type HumanAccountRole = z.infer<typeof humanAccountRoleSchema>;
|
|
29
|
+
/**
|
|
30
|
+
* The minimal shape this package needs out of an already-resolved Clerk
|
|
31
|
+
* session (e.g. the object returned by Clerk's own `auth()` in a Next.js
|
|
32
|
+
* server context). No Clerk SDK type is imported — this is a structural
|
|
33
|
+
* shape the caller supplies directly.
|
|
34
|
+
*/
|
|
35
|
+
export type ClerkSessionLike = {
|
|
36
|
+
orgId?: string | null;
|
|
37
|
+
userId?: string | null;
|
|
38
|
+
orgRole?: string | null;
|
|
39
|
+
};
|
|
40
|
+
export type ResolvedHumanIdentity = {
|
|
41
|
+
tenant: string;
|
|
42
|
+
subject: string;
|
|
43
|
+
role: HumanAccountRole;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* @security ⚠️ DECODE/NORMALIZE-ONLY. NOT AUTHENTICATION. NOT VERIFICATION.
|
|
47
|
+
*
|
|
48
|
+
* This function does NOT authenticate a request and does NOT verify
|
|
49
|
+
* anything. It NORMALIZES a session that the caller has ALREADY verified
|
|
50
|
+
* upstream — e.g. via Clerk's own server-side `auth()` in a Next.js/Convex
|
|
51
|
+
* server context — into this package's `{ tenant, subject, role }` contract.
|
|
52
|
+
*
|
|
53
|
+
* Passing an unverified, client-supplied object here (for example
|
|
54
|
+
* `req.body`, a query-string payload, or anything else an attacker
|
|
55
|
+
* controls) makes THAT object the trusted source of `tenant`, `subject`,
|
|
56
|
+
* AND `role` — a privilege-escalation bug. The shape checks below (missing
|
|
57
|
+
* orgId/userId/orgRole) only guard against malformed input; they perform
|
|
58
|
+
* NO signature check, NO issuer check, NO session-validity check.
|
|
59
|
+
*
|
|
60
|
+
* Callers MUST verify the session upstream (Clerk `auth()` server-side, or
|
|
61
|
+
* an equivalent signed-session verifier) BEFORE calling this function. Only
|
|
62
|
+
* pass in the object Clerk itself already verified — never pass through an
|
|
63
|
+
* unverified request body.
|
|
64
|
+
*
|
|
65
|
+
* Resolves an already-verified, framework-agnostic Clerk session object into
|
|
66
|
+
* the package's `{ tenant, subject, role }` contract.
|
|
67
|
+
*
|
|
68
|
+
* - throws when `session` is null/undefined ("Unauthenticated: no
|
|
69
|
+
* session.") — same refusal message class as `requireTenantId`'s
|
|
70
|
+
* session branch, for consistent error handling across both resolvers.
|
|
71
|
+
* - throws when `session.orgId` is missing or empty ("No active
|
|
72
|
+
* organization on this session...") — human path is cloud fail-closed,
|
|
73
|
+
* same invariant as `requireTenantId`.
|
|
74
|
+
* - throws when `session.userId` is missing or empty.
|
|
75
|
+
* - throws when `session.orgRole` is missing, empty, or not a recognized
|
|
76
|
+
* Clerk org role (unknown roles are refused, never defaulted).
|
|
77
|
+
*
|
|
78
|
+
* Named to make the already-verified precondition explicit at the
|
|
79
|
+
* call-site — see CHANGELOG.md 0.4.0 for the naming history — mirroring
|
|
80
|
+
* `decodeUnverifiedBearer`'s equivalent rename in `./tenancy-domain.ts`.
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* ```ts
|
|
84
|
+
* // session MUST already be verified upstream, e.g.:
|
|
85
|
+
* // const { orgId, userId, orgRole } = await auth(); // Clerk server-side
|
|
86
|
+
* const { tenant, subject, role } = normalizeVerifiedHumanSession({
|
|
87
|
+
* orgId: "org_abc",
|
|
88
|
+
* userId: "user_123",
|
|
89
|
+
* orgRole: "org:admin",
|
|
90
|
+
* });
|
|
91
|
+
* // -> { tenant: "org_abc", subject: "user_123", role: "admin" }
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
export declare function normalizeVerifiedHumanSession(session: ClerkSessionLike | null | undefined): ResolvedHumanIdentity;
|
|
95
|
+
//# sourceMappingURL=human-path.d.ts.map
|