@vantageos/cloud-identity
Building blocks for multi-tenant authorization: decide which tenant a caller belongs to, and keep every other tenant's rows out of the response.
If your service holds data for several customers behind one deployment, you answer the same four questions on every request — who is calling, which tenant they belong to, which rows they may see, and whether their token is genuine. This package answers them once, in plain functions with no framework attached, so each service does not re-implement (and re-misimplement) its own version.
It depends on no web framework, router or database, and runs anywhere JavaScript runs.
Install
npm install @vantageos/cloud-identity
Node 18 or later. ESM only.
Quick start
import { requireTenantId, scopeFilterList } from "@vantageos/cloud-identity";
// 1. Which tenant is this caller in? Throws if the answer is "none".
const tenantId = requireTenantId({
kind: "session",
identity: { userId: "user_123", orgId: "org_abc" },
});
// 2. Which of these rows may they see?
const ctx = {
fromAllowList: ["reports"],
namespaceReadPrefixes: ["team/finance"],
namespaceWritePrefixes: [],
};
const rows = [
{ createdBy: "reports", namespace: "team/finance" }, // kept
{ createdBy: "someone-else", namespace: "team/legal" }, // dropped
];
console.log(tenantId); // "org_abc"
console.log(scopeFilterList(ctx, rows)); // only the first row
Every symbol is available from the package root. Subpath imports
(@vantageos/cloud-identity/scope-filter and friends) also work if you prefer
to be explicit about where something comes from.
In a bundle (a Convex function, an edge worker), import by subpath. The
root re-exports clerk-handshake, which pulls @clerk/backend: importing even
requireTenantId from the root bundles 33 Clerk modules. The subpath does not:
import { requireTenantId } from "@vantageos/cloud-identity/org-guard"; // 0 Clerk modules
Measured with esbuild on 0.23.3: the root import of requireTenantId is
599,033 bytes (33 @clerk modules), the ./org-guard import 1,065 bytes
(none). test/org-guard-subpath.test.ts re-measures it on every run.
A host-verified identity to its tenant
When the host has already verified the caller (a Convex function holds
await ctx.auth.getUserIdentity()), requireTenantFromIdentity returns the
tenant ID from the identity's organisation ID claim, read by the package's own
claim reader. It throws IdentityRefusalError (RBAC_DENIED,
no-verified-organisation) when there is no identity or no organisation ID;
there is no fallback to a slug or a default.
import { requireTenantFromIdentity } from "@vantageos/cloud-identity";
const tenantId = requireTenantFromIdentity(await ctx.auth.getUserIdentity(), {
door: "engine:queue.enqueue",
});
For an audit stamp (createdBy), requireSubjectFromIdentity(identity, { door })
(0.21.3) returns the same verified identity's subject, and throws
IdentityRefusalError (RBAC_DENIED, no-verified-subject) when there is no
identity or the subject is not a non-empty string.
A service calling Convex: the machine credential
A service with no human behind it (no Clerk session, so no
ctx.auth.getUserIdentity()) calls public Convex functions with a machine
credential (0.21.4). The package decides the identity; the consumer supplies
one indexed read.
Mint. mintMachineCredential() returns a MintedMachineCredential:
credential (vpmc_ + 32 random bytes, base64url) and secretHash (its
SHA-256 hex digest). Store ONLY secretHash, with tenantId, optional
agentId, revoked and expiresAt (the StoredBearerRow shape). Hand
credential to the service once.
Transport. Convex functions do not receive request headers, so the credential reaches the function as an ARGUMENT, next to the organisation the service claims to act for. Since 0.25.0 that argument is the credential's DIGEST, never the credential: see "Convex consumers: pass the digest, never the token" below. Rotate by minting a new credential and revoking the old row.
Resolve. In a Convex function, resolveConvexMachineCallerFromDigest({ credentialDigest, claimedOrgId }, deps) (ConvexMachineCallerDigestInput).
For a caller that holds the raw credential in-process, outside any Convex
argument, resolveConvexMachineCaller({ credential, claimedOrgId }, deps)
(ConvexMachineCallerInput, ConvexMachineCallerDeps = PresentedBearerDeps
plus door) returns a ConvexMachineCallerResult: { ok: true, tenantId, agent, scopes } or { ok: false, refusal }. It runs the same core as
validatePresentedBearer (digest lookup, constant-time re-compare, row shape,
revocation, expiry). Tagged @callerResolver.
import { IdentityRefusalError } from "@vantageos/cloud-identity/identity-refusal";
import { resolveConvexMachineCallerFromDigest } from "@vantageos/cloud-identity/machine-caller";
const r = await resolveConvexMachineCallerFromDigest(
{ credentialDigest: args.credentialDigest, claimedOrgId: args.orgId },
{
door: "engine:world.step",
lookupBySecretHash: (d) =>
ctx.db
.query("machine_credentials")
.withIndex("by_secret_hash", (q) => q.eq("secretHash", d))
.unique(),
},
);
if (!r.ok) throw new IdentityRefusalError(r.refusal);
const tenantId = r.tenantId;
Rejected alternative: a custom JWT provider on Convex auth, because it needs the consumer to run a token issuer.
Convex consumers: pass the digest, never the token
A raw bearer passed as a Convex function argument leaks. Measured: when any argument of a Convex call fails its validator, Convex echoes the WHOLE argument object, valid fields included, into the caller's error, and that error is persisted in the deployment log. One malformed call writes the token to a log.
So hash at the HTTP boundary and send only the SHA-256 hex digest (0.25.0):
- HTTP boundary (MCP server, route handler):
sha256Hex(token)from@vantageos/cloud-identity/crypto. It is the same function that produces the storedsecretHash(andmintMachineCredential's), so the digests agree by construction. Forward the digest; drop the token. - Convex function: resolve the digest.
resolveConvexMachineCallerFromDigest({ credentialDigest, claimedOrgId }, deps)from@vantageos/cloud-identity/machine-callerfor a machine caller.resolvePresentedDigest(digestHex, deps)from@vantageos/cloud-identity/presented-bearerfor a presented bearer; it returns the sameValidatePresentedBearerResultasvalidatePresentedBearer.
A digest door is reachable only by the service identity. The stored
secretHash IS the digest, so once a door accepts a digest, a stored digest is
a presentable credential: read access to the token table becomes equivalent to
holding every live token. Expose the Convex function that calls
resolvePresentedDigest / resolveConvexMachineCallerFromDigest to the
deployment's service identity only (an internal function, or a door that refuses
any other caller by name), never to a client, and treat the token table's read
access as security-critical.
Both run the one decision the raw-token functions run after hashing: lookup by
digest, constant-time re-compare of the stored digest, row shape, revocation,
expiry. The raw-token functions (validatePresentedBearer,
resolveConvexMachineCaller) are unchanged; they hash and call the same code,
and test/presented-digest.test.ts pins identical results for every outcome.
Only a 64-character lowercase hex digest is accepted. Anything else is refused
with the SAME refusal as a well-formed digest that matches no row (mismatch,
or CREDENTIAL_REFUSED credential-invalid), without a lookup, so the shape
of the input tells a prober nothing. The consumer's onRefusal sink still
receives the internal reason malformed-digest.
A digest is still the key of a live credential: whoever holds it can present it to a digest door. Do not log it on purpose. What it does not do is reveal the token.
Import from the narrow subpaths in a Convex bundle. The root index re-exports
the Clerk handshake: measured with esbuild, importing
resolveConvexMachineCallerFromDigest from the root bundles 33 @clerk
modules, from ./machine-caller none (test/machine-caller-subpath.test.ts).
// HTTP boundary
import { sha256Hex } from "@vantageos/cloud-identity/crypto";
await convex.mutation(api.engine.step, {
orgId,
credentialDigest: await sha256Hex(credential),
});
// Convex function: args = { orgId: v.string(), credentialDigest: v.string() }
import { resolveConvexMachineCallerFromDigest } from "@vantageos/cloud-identity/machine-caller";
const r = await resolveConvexMachineCallerFromDigest(
{ credentialDigest: args.credentialDigest, claimedOrgId: args.orgId },
{ door: "engine:step", lookupBySecretHash: (d) => lookupRow(ctx, d) },
);
Both caller paths, one contract
A right is never granted by an absent argument. requireTenantId refuses by
default — a missing session, a missing organization, or a missing
bearer-resolved workspace all throw; there is no branch that falls through to
"full access" when tenant information is absent. The same function resolves
both entry paths a VantagePeers Cloud request can arrive on:
import {
requireTenantId,
decodeUnverifiedBearer,
type TenantSource,
} from "@vantageos/cloud-identity";
async function resolveTenant(req: {
session?: { orgId?: string | null } | null;
bearerToken?: string;
}): Promise<string> {
const source: TenantSource = req.session
? { kind: "session", identity: req.session }
: {
// decodeUnverifiedBearer only decodes the shape — verify the token's
// signature (or look it up against your own store) before trusting
// any field on the payload. See "What this package does not do".
kind: "bearer",
context: await decodeUnverifiedBearer(req.bearerToken!),
};
// Throws for BOTH paths if no tenant is attached — never returns a
// "default" tenant id when one is missing.
return requireTenantId(source);
}
// Human path: a signed-in user with an active organization.
await resolveTenant({ session: { orgId: "org_abc" } }); // -> "org_abc"
// Machine path: a caller presenting an already-resolved bearer token.
await resolveTenant({ bearerToken: someBase64Token }); // -> the token's workspaceId
// Either path with nothing attached throws instead of granting access.
await resolveTenant({ session: null }); // throws Error("Unauthenticated: no session.")
What each function does
Tenant resolution and membership
requireTenantId(source)— returns the caller's tenant id, or throws if there is none. It accepts both entry paths behind one contract:{ kind: "session", identity }for a signed-in human, and{ kind: "bearer", context }for a machine caller. A missing session, a missing organization and an empty organization id are all refusals, never a default.resolveTenantIdOrAbsent(source)— the NON-throwing sibling ofrequireTenantId, for a public READ. SameTenantSourceunion, same refusal conditions; instead of throwing it returns{ present: true, tenantId }or{ present: false, absence: { code: "TENANT_ABSENT", reason } }(TenantAbsence,TenantAbsenceReason,TenantResolution). The absence is a non-empty object: it is never[],{},nullor a zeroed count, so "no organization" and "an organization with nothing in it" cannot collapse into one value. UserequireTenantIdat a WRITE boundary andresolveTenantIdOrAbsentwhere a throw would crash a mounted render. The tenant id is OPAQUE — returned byte-for-byte, never a slug or display name.requireHumanRole(session, required)— asserts the VERIFIED session carriesrequired(aHumanAccountRoleor a list of them, exact match, no hierarchy) and returns the resolved identity; throws otherwise. The role is read fromsession.orgRole, never from an argument.RequiredHumanRoleis itsrequiredtype.resolveHumanRoleOrRefusal(session, required)— the non-throwing form:{ admitted: true, identity }or{ admitted: false, refusal: { code: "ROLE_REFUSED", reason } }(RoleResolution,RoleRefusal,RoleRefusalReason).reasonisrole-not-heldfor a verified member lacking the role.getEffectiveTenantId(ctx, args)— resolves which tenant a request should act on when a caller may legitimately act on more than one.
Resolving a presented bearer token
validatePresentedBearer(authHeader, deps)— takes the rawAuthorizationheader a non-master caller presented, and returns either a resolved identity or a coarse refusal. It hashes the token, asks yourdeps.lookupBySecretHashcallback for the row carrying that digest, re-compares the stored digest in constant time, and then checks revocation and expiry. Your callback does one indexed read; every decision stays in the package, so two consumers cannot drift into two different answers to "who is calling".- Your callback receives the digest, never the token. A raw secret
therefore never reaches your query, your query log or your slow-query log.
Use
sha256Hexto produce the same digest when you WRITE a token row, so the two sides agree. - An unknown token, a revoked token and an expired token return one
identical value (
{ ok: false, error: "mismatch" }). Someone probing your endpoint cannot learn from the reply whether a token was ever real. Passdeps.onRefusalto receive the distinction (PresentedBearerInternalReason) in your own logs, where it belongs. - A failing lookup refuses. If your callback throws, the result is
{ ok: false, error: "unavailable" }— a refusal you can answer with 503 instead of 401. There is no branch that grants anything when the lookup fails. deps.nowis an injectable clock, so expiry is testable without waiting.- Types:
PresentedBearerDeps,StoredBearerRow(validated at runtime bystoredBearerRowSchema),ResolvedBearerIdentity,ValidatePresentedBearerResult.
- Your callback receives the digest, never the token. A raw secret
therefore never reaches your query, your query log or your slow-query log.
Use
requireAgentScopedIdentity(identity)— narrows a resolved identity to a surface that acts as ONE agent, and refuses when the token has no agent. A token can be organization-wide: it names a tenant and no agent. That is a legitimate credential, and it must never be silently widened into whichever agent the surface happened to be about. So the agent is not a nullable field you have to remember to check —ResolvedBearerIdentity.agentis anAgentPresence, a union you must narrow, and the per-agent form (AgentScopedIdentity, with a plainagentId: string) can only be obtained from this function. ReturnsRequireAgentScopedResult; its refusal is distinct from a bad credential, because the right answer there is 403, not 401.sha256Hex(input)— the lower-case hex SHA-256 digest of a string. Exported so that the code storing a token row hashes it exactly the way the resolver will recompute it. Hash once, store the digest, discard the token.
Row filtering
passesScopeFilter(ctx, row)— true when this row is visible to this caller. Deprecated since 0.11.0 (see below).scopeFilterList(ctx, rows)— the subset of rows the caller may see. Deprecated since 0.11.0 (see below).scopeFilterGet(ctx, row)— the row, ornullif the caller may not see it. Deprecated since 0.11.0 (see below).isMasterScope(ctx)/isWildcardScope(ctx)— true when the caller holds unrestricted access. Useful for skipping filtering you know is pointless; never as a substitute for it. Decided onscope === "master". A"*"entry infromAllowListstill counts as master, deprecated since 0.24.0: declarescope: "master"instead. It is removed in a major release.
Deprecated since 0.11.0 (pointing to filterTargetsBelongingTo since 0.14.0): passesScopeFilter, scopeFilterGet and
scopeFilterList. All three (the last two through the first) admit a row
when its createdBy NAME appears in the caller's fromAllowList, so a name
selects the row, which backend standard R-53 clause 1 forbids. For a target
that carries an organisation or owner ID, use resolveActingPrincipal and
assertTargetBelongsTo, which compare stored IDs (see "The acting principal
by ID, and the target checked by ID"). Their behaviour is unchanged; they stay
exported until consumers migrate off them, and are removed in a major release.
Visibility is granted two ways: the row's createdBy appears in the caller's
fromAllowList, or the row's namespace sits under one of the caller's
namespaceReadPrefixes. Prefixes match on a path boundary, so a caller allowed
team/finance does not thereby see team/finance-archive.
fromAllowList is optional since 0.24.0. An absent list, or a value that is
not an array, names nobody: no row is visible through createdBy or a grant
field, and the caller is not master through it. Namespace prefixes work as
before.
A third way is opt-in per call. All three functions take an optional last
argument, grantFields (type GrantFieldDeclaration, a list of property
names). It names the row properties that carry per-row grants, such as a
mission's agents. A row is then also visible when one of those properties
names an identity in the caller's fromAllowList. The property can hold a
single string or an array of strings; a value of any other shape is skipped,
not matched. The list is data supplied by your call site; the package
hardcodes no field names. Omitting it, or passing [], adds nothing.
Token validation
validateMasterBearer(header, secret)— compares anAuthorization: Bearerheader against a shared secret in constant time, and reports why it failed: header absent, header malformed, or value mismatched.timingSafeEqual(a, b)— constant-time byte comparison, if you need to build your own check.
Shapes
Zod schemas and their inferred TypeScript types for workspaces, members,
roles and tenant context: workspaceSchema (type Workspace),
workspaceMemberSchema (type WorkspaceMember), workspaceRoleSchema
(type WorkspaceRole), tenantContextSchema (type TenantContext) — plus
ScopeViolationError (payload type ScopeViolationPayload), for refusals you
want to catch by type, and BearerPayload, the type returned by
decodeUnverifiedBearer.
Supporting types (no runtime code — import with import type)
OAuthCtx— the context object every scope-filter function above requires:{ fromAllowList?, namespaceReadPrefixes, namespaceWritePrefixes, scope? }(fromAllowListoptional since 0.24.0).ScopeProfile,NamespacePrefix,FromAllowListEntry— the field types that make upOAuthCtx.ValidateMasterBearerResult— the return type ofvalidateMasterBearer.ScopeFilterable— the minimal row shape ({ createdBy?, namespace? }) accepted bypassesScopeFilter/scopeFilterList/scopeFilterGet.GrantFieldDeclaration—readonly string[], the optionalgrantFieldsargument of those three functions.SessionIdentity— the human-path shaperequireTenantIdaccepts under{ kind: "session", identity }(see "Both caller paths, one contract").TenantSource— the discriminated unionrequireTenantIdaccepts:{ kind: "session", identity },{ kind: "bearer", context }, or{ kind: "self-host", tenantId }.DeploymentMode— the named"cloud" | "self-host"literal union (0.4.0).
Deployment mode and the human path (0.4.0, additive)
-
DeploymentMode("cloud" | "self-host") names the two ways this package can be run.cloudkeepsrequireTenantId's existing session/bearer behaviour exactly as-is — organization/workspace REQUIRED, fail-closed.self-hostis single-tenant: pass{ kind: "self-host", tenantId }torequireTenantId, and it returnstenantIdwhen it is a non-empty string. The self-host tenant id must be DECLARED via configuration — if you select self-host mode without configuring a tenant id,requireTenantIdthrows rather than returning a default. A right is never granted by absence, whichever mode you are in.import { requireTenantId } from "@vantageos/cloud-identity"; // self-host: the tenant id is configured explicitly, once, at startup. requireTenantId({ kind: "self-host", tenantId: "the-one-tenant" }); // -> "the-one-tenant" requireTenantId({ kind: "self-host", tenantId: undefined }); // -> throws: "No tenant id configured for self-host mode..." -
normalizeVerifiedHumanSession(session)— normalizes, does not authenticate. Maps an already-verified (not merely well-shaped), framework-agnostic Clerk session object ({ orgId, userId, orgRole }) to{ tenant, subject, role }. This package never imports a Clerk SDK; the caller MUST verify the session upstream (e.g. Clerk's server-sideauth()) and pass in the already-verified object — never an unverified, client-supplied object such asreq.body, which would make the caller's input the trusted source of tenant/subject/role. Refuses (throws) when the session has no organization, no user id, or an org role this package does not recognize — never defaults silently; these are shape checks, not a verification step. -
humanAccountRoleSchema(typeHumanAccountRole) — the new"owner" | "admin" | "member" | "client"role union returned bynormalizeVerifiedHumanSession. Distinct fromworkspaceRoleSchema(Admin | Editor | Viewer) — one is an organization-account role, the other is workspace membership; they are never conflated. -
ClerkSessionLike— the minimal input shapenormalizeVerifiedHumanSessionaccepts:{ orgId?, userId?, orgRole? }. -
ResolvedHumanIdentity— the{ tenant, subject, role }return type ofnormalizeVerifiedHumanSession.import { normalizeVerifiedHumanSession } from "@vantageos/cloud-identity"; normalizeVerifiedHumanSession({ orgId: "org_abc", userId: "user_123", orgRole: "org:admin", }); // -> { tenant: "org_abc", subject: "user_123", role: "admin" }
Authorize a person and bind an organisation
A connector that signs in a PERSON (a Claude or ChatGPT user) must end up with a token naming that person and one organisation they belong to, never an anonymous app. Clerk is the identity provider; this package holds the decisions and takes every collaborator as a parameter.
// GET /authorize
const out = await startAuthorize(query, clerkSessionToken, cfg, deps);
// GET /authorize/callback (the return from Clerk) and the picker's POST
const out2 = await resumeAuthorize({ state, sessionToken, orgId, approved, consentToken }, cfg, deps);
// POST /token (authorization_code grant)
const res = await exchangeAuthorizationCode(
{ code, codeVerifier, redirectUri, clientId, resource },
{ codeStore },
);
startAuthorize(params, sessionToken, config, deps)validates the request (registered client, exactredirect_uri, a PKCE challenge using the SHA-256 method, an allowed RFC 8707resource, supported scopes). With no valid Clerk session it returns{ kind: "redirect-to-sign-in", url }and issues nothing; the return URL carries a state blob signed with HMAC (AuthorizeConfig.stateSecret, at least 32 characters) and valid for 600 seconds by default.resumeAuthorize(input, config, deps)rebuilds the request ONLY from that signed state (a tampered or expired blob is refused), verifies the Clerk session token (verifyClerkSessionToken: RS256 against the key set the consumer supplies, issuer, expiry, optional audience and authorized parties), lists the user's organisations throughdeps.listMemberships, and answers with anAuthorizeOutcome:redirect-to-sign-in,org-picker(anOrgPickerModelthe consumer renders; it lists only the user's own organisations),redirect-to-client(the URL carrying the code and the client'sstate) orrefused.OrgPickerModel.redirectOriginis the origin of the registeredredirect_urithe person will be sent to, for the consent page to display (display-only; it grants nothing). Denial: postdenied: true(an explicit flag, never inferred from a missingapproved). Once the state, the client and the session verify,resumeAuthorizereturnsredirect-to-clientcarryingerror=access_deniedand the client'sstate. This package never addsiss: the RFC 9207issis appended by the consumer (the authorization server that owns the issuer) to everyredirect-to-clientoutcome, success and denial alike. A tampered or expired state, an unknown client or a failed session is a refusal, never a redirect;deniedwithapproved: trueis refused asinvalid-request. With consent on (the default), a denial must also post the picker'sconsentToken, verified by the same function as an approval (0.18.0): a missing, empty, forged or foreign token is refused asconsent-required, the same outcome the approve path returns, and a valid one still redirects withaccess_denied. A denial is a claim in a POST body like an approval; without the token it could be posted for a victim's session. WithrequireConsent: falseno token exists and none is required. A postedorgIdis honoured only if the verified user's own membership list contains it. Consent is ON by default (requireConsentdefaults to true). What it protects: a code is issued only after the person was SHOWN the picker and approved it. The picker'sOrgPickerModelcarries aconsentToken:HMAC(stateSecret, "consent-v1" NUL state NUL clerkUserId NUL sorted organisation ids). It is bound to the exact signed state (useless for any other request), to the verified user (useless under another session) and to the organisation set that user was shown.resumeAuthorizewithapproved: truerefuses withconsent-requiredunless the presentedconsentTokenverifies, compared in constant time, for that state AND the session's user. Without this, a client could mint its own state (the sign-in redirect hands it one), replay it with a victim's session andapproved: true, and receive a code the victim never saw a screen for. The consumer rendersconsentTokenas a hidden field of the picker's POST form and posts it back asconsentToken. The token IS the CSRF protection for that POST, because a cross-site form cannot know it; do not add a weaker substitute and do not accept the POST without it. Also setsession.authorizedPartiesto your own origin(s) so a Clerk session token minted for another application (azp) is not accepted here. SetrequireConsent: falseexplicitly ONLY for first-party clients whose redirect URIs you control; exactly one organisation is then auto-picked and no token is involved.- The code is bound to
{ clerkUserId, orgId, orgSlug, orgRole, clientId, redirectUri, codeChallenge, resource }, stored as a digest through the consumer'sAuthorizationCodeStore(put, and an ATOMICconsume), single use, 60 seconds by default. Records areAuthorizationCodeRecord; a consume answersConsumeCodeResult.AuthorizeClient,ClerkOrgMembership,AuthorizeDeps,ResumeInputandClerkJwk/ClerkJwks/VerifiedClerkSession/VerifyClerkSessionResult/ClerkSessionVerifierConfigare the shapes the consumer supplies or reads. exchangeAuthorizationCode(input, deps)consumes the code FIRST (every attempt burns it), then checks expiry, client,redirect_uri, resource and the PKCE verifier (pkceChallengeFromVerifierderives a challenge). It returnsAuthorizedTokenClaims:{ sub, org_id, org_slug, org_role, aud, client_id, scope }(ExchangeInput,ExchangeDeps,ExchangeResult). No field comes from a client-registration profile.buildDiscoveryDocument(config)builds the discovery document (DiscoveryConfig,DiscoveryDocument,DiscoveryResult); the issuer must be https with no query. It advertises only what is implemented: the code flow, PKCE, theauthorization_codegrant and UserInfo. It omitsid_token_signing_alg_values_supported(OIDC Discovery lists it as required for a provider that issues id_tokens; this package issues none, so that is a documented deviation), refresh tokens and client-authentication methods. A consumer that implements any of them adds those keys itself.buildUserInfo(claims, user)returnssub, andemail/email_verifiedwhen the token carriesemail(ClerkUserLike,UserInfo,UserInfoResult).- Every refusal is
{ code: "AUTHORIZE_REFUSED", reason }(AuthorizeRefusal,AuthorizeRefusalReason), the same shape asRoleRefusal.oauthErrorFor(refusal)maps it to an OAuth error code and an HTTP status (503 for a collaborator that failed, so an outage is not reported as a bad credential). A collaborator that throws is always a refusal.
Also exported as subpaths: /authorize, /token-exchange, /clerk-session,
/oidc.
Limits, stated: redirect_uri matching is exact (no loopback port variation);
the package signs no access token and no id token and issues no refresh token (the consumer mints the token
from the claims); it fetches no key set and calls no Clerk API itself.
Wiring person sign-in in an MCP server
A person signed in through an OAuth connector reaches your MCP server holding a
bearer you minted for them (see "Authorize a person and bind an organisation").
@vantageos/cloud-identity/person-principal turns that bearer's token record
into the person's principal and answers the three questions every write needs.
It is pure: you do the lookups, it decides. A server that serves people (a PDF
inspector, a registry, a CRM) wires it the same way:
import {
resolvePersonPrincipal,
resolvePersonActingName,
resolvePersonTenantAccess,
resolveWriterRole,
type PersonPrincipal,
type PersonRefusal,
} from "@vantageos/cloud-identity/person-principal";
// 1. Who is calling: the token row you looked up by the bearer's digest.
const who = await resolvePersonPrincipal(
{ principal: row.principal, subject: row.userId, orgSlug: row.orgSlug,
orgRole: row.orgRole, revokedAt: row.revokedAt, expiresAt: row.expiresAt },
{ now: Date.now(), lookupOrganisation: (slug) => orgTable.get(slug) },
"my-tool",
);
if (!who.ok) return deny(who.refusal);
// 2. Under what name: the token's, never the argument's.
// Deprecated since 0.11.0: a new door uses resolveActingPrincipal instead.
const name = resolvePersonActingName({
principal: who.principal, claimedName: args.createdBy,
agentCredential: presentedAgent, door: "my-tool",
});
if (!name.ok) return deny(name.refusal);
// 3. Which rows, and may they write.
const inOrg = resolvePersonTenantAccess({
principal: who.principal, rowOrgId: record.orgId, door: "my-tool" });
const mayWrite = resolveWriterRole({
role: who.principal.orgRole, writerRoles: writerRolesOfOrg, door: "my-tool",
orgSlug: who.principal.orgSlug });
resolvePersonPrincipal(token, deps, door)refuses a token that is expired, revoked, not a person's, bound to no organisation, or bound to an inactive or unmapped one. It looks up only the token's own organisation.- Deprecated since 0.11.0:
resolvePersonActingName. It takes names (claimedName,agentCredential.agentName) as identity input, which backend standard R-53 clause 4 makes non-conforming for any new door. UseresolveActingPrincipalandassertTargetBelongsTo(see "The acting principal by ID, and the target checked by ID"). Its behaviour is unchanged; it will be removed in a major release once consumers have migrated off it. resolvePersonActingNamereturns{ ok: true, actingAs: "person", actor }for no name or the person's own (user:<subject>), and refuses another user's name (PERSON_ACTS_AS_ITSELF) and an agent's name without that agent's own credential (AGENT_CREDENTIAL_REQUIRED, orAGENT_IDENTITY_MISMATCHwhen the credential belongs to another agent).checkPersonCallShapeis the same rule for a backend door that receives a person's call already stripped of its name.PERSON_ACTOR_PREFIX,personActorNameandisPersonActorNamespell and reserve the actor name: refuse it when someone registers an agent.resolvePersonTenantAccessis a strict equality on the organisation; an unstamped row is refused.resolveWriterRole/requireWriterRolecheck the verified role against a writer allowlist you hold as data. A missing role, a missing list and an empty list all refuse.requireWriterRolethrowsPersonRefusalError.- Every refusal is a
PersonRefusal(PersonRefusalCode,PersonRefusalReason,door,detail); you choose the transport shape.personTokenRecordSchema,PersonTokenRecord,PersonPrincipal,OrganisationState,PersonPrincipalDeps,PersonPrincipalResult,PersonActingNameResultandPersonAccessResultare the input and result types.
The module verifies no credential. The token record must come from your own lookup of the bearer the person presented, never from a request body.
Wiring the authorize runtime (/authorize-runtime, 0.21.7)
The decisions (startAuthorize, resumeAuthorize, exchangeAuthorizationCode,
buildDiscoveryDocument) take their collaborators as parameters.
@vantageos/cloud-identity/authorize-runtime is the glue that builds them, so an
MCP server does not copy it. It is store-agnostic and framework-agnostic: you
inject the client registry, the code storage and the membership lister; it reads
no process.env, opens no connection and logs no value.
import {
buildAuthorizeRuntime,
codeStoreFrom,
createClerkDirectory,
pickerContentSecurityPolicy,
readAuthorizeSettings,
renderOrgPicker,
} from "@vantageos/cloud-identity/authorize-runtime";
import { createClerkClient } from "@clerk/backend";
const read = readAuthorizeSettings(process.env);
if (!read.ok) return respond503(read.missing); // names only, never values
const clerk = createClerkDirectory(createClerkClient({ secretKey: process.env.CLERK_SECRET_KEY }));
const { cfg, deps } = buildAuthorizeRuntime({
settings: read.settings,
lookupClient: (clientId) => myRegistry.find(clientId),
listMemberships: clerk.listMemberships, // filter it here to the orgs your server can serve
codeStore: codeStoreFrom({ insert: db.putCode, takeOnce: db.consumeCode }),
});
// GET /authorize -> startAuthorize(query, sessionToken, cfg, deps)
// GET /authorize/callback -> resumeAuthorize({ state, sessionToken }, cfg, deps)
// POST /authorize/org -> resumeAuthorize({ state, sessionToken, orgId, approved, consentToken }, cfg, deps)
// On kind "org-picker":
// body = renderOrgPicker(outcome.model, { productName: "My Server" })
// header = { "Content-Security-Policy": await pickerContentSecurityPolicy() }
// POST /token (authorization_code) -> exchangeAuthorizationCode(input, { codeStore: deps.codeStore })
readAuthorizeSettings(env, { requireClerkSecretKey? }) reads
AUTHORIZE_STATE_SECRET (at least 32 characters), CLERK_DOMAIN,
AUTHORIZE_SIGN_IN_URL, AUTHORIZE_CALLBACK_URL, PUBLIC_BASE_URL,
CLERK_SECRET_KEY and the optional comma list AUTHORIZE_AUTHORIZED_PARTIES
from the object you pass. It returns { ok: true, settings } or
{ ok: false, missing, refusal }, where missing holds the NAMES of every
absent or unusable variable and refusal is server-misconfigured. Pass
requireClerkSecretKey: false when you inject your own membership lister.
AUTHORIZE_ENV lists the names; DEFAULT_AUTHORIZE_SCOPE is mcp:full openid.
Types AuthorizeEnv, AuthorizeSettings, AuthorizeSettingsResult,
ReadAuthorizeSettingsOptions.
createClerkJwksSource({ issuer, fetch?, now?, ttlMs? }) is the
session.jwks function: it serves the key set from its own cache for ttlMs
(default ten minutes) and refetches after. A failed or malformed answer throws
and is not cached; the flow then refuses jwks-unavailable. buildAuthorizeRuntime
creates one unless you pass jwks. Types JwksFetch, ClerkJwksSourceOptions.
codeStoreFrom({ insert, takeOnce }) adapts your storage to
AuthorizationCodeStore. takeOnce(codeHash) must be atomic. Its answer is
checked: ok with a complete record, unknown and already-used pass; anything
else throws, and the exchange answers code-store-unavailable.
createInMemoryCodeStore() is a single-use store for tests and a one-instance
development server. Type CodeStoreBackend.
createClerkDirectory(clerk, { pageSize?, maxPages? }) returns
listMemberships and getUser over a Clerk Backend client you created. The list
is read in full; a Clerk failure or a list that does not end within maxPages
throws, so a partial list is never offered. Types ClerkUsersApi,
ClerkDirectoryOptions, ClerkDirectory.
renderOrgPicker(model, { productName, formAction? }) returns the consent
page (default action /authorize/org); every interpolated value is escaped and
the hidden consentToken is the CSRF protection of the post. Send
await pickerContentSecurityPolicy() as its Content-Security-Policy: no
scripts, no external assets, no framing, one inline style admitted by hash. Type
OrgPickerOptions.
buildAuthorizeRuntime({ settings, lookupClient, listMemberships, codeStore, jwks?, now?, generateCode?, resourcePath?, defaultScope?, supportedScopes? })
returns { cfg, deps }. The only resource codes are issued for is
<baseUrl><resourcePath> (default /mcp). Consent stays on. Types
AuthorizeRuntimeOptions, AuthorizeRuntime.
Role policy, membership, namespace writes and secrets (0.10.0)
Seven primitives that consumers used to copy. Each is pure, takes its policy as
DATA from the call site, and refuses on every absence: no role, no claim, no
list, no prefix, no secret and a failing lookup are all refusals, never a
default grant. Refusals share one shape, IdentityRefusal (code, reason,
door, detail), thrown inside an IdentityRefusalError by the asserting
forms. IdentityRefusalCode is RBAC_DENIED or CREDENTIAL_REFUSED;
IdentityRefusalReason is the closed list of reasons. Every function that
refuses accepts an optional door so the refusal names your own entry point.
Minimum role over an order you supply (./role-policy)
resolveMinRole({ role, minimum, order, door? })—orderis your ordered list, MOST privileged first (for example["admin", "editor", "viewer"]). Returns{ ok: true, role }or{ ok: false, refusal }(MinRoleInput,MinRoleResult). Refuses an empty or duplicated order, aminimumoutside the order, an absent or unknown role, and a role below the minimum. Matching is exact.assertMinRole(input)— the throwing form; returns the role or throwsIdentityRefusalError.
Role-claim mapping with an explicit fallback (./role-policy)
mapRoleClaim({ claim, mapping, fallback?, door? })— maps a verified claim (for example"org:admin") to your role throughmapping, your data (MapRoleClaimInput,MapRoleClaimResult). The package never picks a default: an absent or unmapped claim is served only when YOU passfallback(use your least-privileged role), and is refused otherwise. A claim that is not a string counts as absent; prototype keys are never mapped. The result says whether the role wasmappedor came from thefallback.
Membership and tenant visibility (./tenant-membership)
resolveMembership({ subject, orgId, lookup, door? })— async.lookupis your read of one membership (MembershipRecord:active, optionalrole, optionalorgId). A throwing lookup, a miss, an inactive row, a malformed row and a row naming another organisation are all refusals; the lookup's own error text is not surfaced. Served result:{ ok: true, membership }(ResolvedMembership,ResolveMembershipInput,ResolveMembershipResult).membershipRecordSchemais the zod schema of the record.isRowInTenant({ rowOrgId, callerOrgId })—trueonly on strict equality of two non-empty ids. An unstamped row is never visible, not even to a caller with no organisation.requireOrgAdmin({ verifiedOrgId, targetOrgId, role, adminRoles, door? })— the organisation-admin proof, bound to the caller's VERIFIED organisation: the target must equal it, and the role must be one ofadminRoles(your data; an empty list admits nobody). ThrowsIdentityRefusalError(RequireOrgAdminInput).
Namespace writes (./namespace-write, ./scope-filter)
assertNamespaceWrite(oauthCtx, namespace, door?)— throws unless the caller'snamespaceWritePrefixesadmit the namespace. Master scope passes; a non-master with no write prefix is refused; read prefixes never grant a write; an empty-string prefix grants nothing.namespaceMatchesPrefix(namespace, prefix)— the one path-boundary rule: equality, or the prefix followed by/.team/finance-archiveis not insideteam/finance. The read filter andassertNamespaceWriteboth use it.
Synchronous constant-time comparison (./secret-compare-sync)
timingSafeEqualSync(a, b)— strings (UTF-8) orUint8Array, pure JavaScript, no early exit on content. It walks the longer length and folds a length difference into the result. Any other input type isfalse. The asynctimingSafeEqualis unchanged.assertSecretSync(presented, expected, door?)— throwsIdentityRefusalError(CREDENTIAL_REFUSED) on mismatch. An unsetexpected(undefined, null or empty) matches nothing, so a missing configuration never opens a door. The refusal carries neither value.
The acting principal by ID, and the target checked by ID (0.11.0)
Why this exists. Two organisations each had an agent displayed as eta.
An organisation A agent, calling through the shared service account and naming
itself eta, completed, updated and deleted organisation B's eta task at ten
different task doors. Every door admitted the call by comparing the typed name
with a name stored on the task. The names were equal; the two agents were not.
Nothing compared an ID.
The rule this module carries: an actor and a target are identified by their stored IDs. A name is a display label; it never selects a row and never authorises one. Resolving the principal and checking the target both happen here, so a product never writes its own identity layer.
import {
resolveActingPrincipal,
assertTargetBelongsTo,
} from "@vantageos/cloud-identity";
// The operator org is DATA: the org whose mapping row says orgKind "operator".
const orgKindOf = async (id) => (await db.orgs.get(id))?.orgKind ?? null;
// 1. Who is acting? IDs from the VERIFIED credential, rows read by ID.
const who = await resolveActingPrincipal(
{ kind: "service", serviceAccountId: "svc_a", actingForAgentId: "agent_a_eta" },
{
agentById: (id) => db.agents.get(id), // -> { id, orgId, active }
serviceAccountById: (id) => db.services.get(id),
organisationById: (id) => db.orgs.get(id), // -> { id, active }
orgKindOf, // -> "operator" | "client" | null
},
"tasks:complete",
);
if (!who.ok) throw toHttpError(who.refusal); // RBAC_DENIED, typed reason
// 2. Does the target belong to them? Stored IDs against resolved IDs.
const task = await db.tasks.get(taskId);
const may = await assertTargetBelongsTo(
who.principal,
{ orgId: task.orgId, ownerId: task.ownerId },
{ orgKindOf },
{ ownerOnly: true, door: "tasks:complete" },
);
if (!may.ok) throw toHttpError(may.refusal);
Organisation B's eta task carries orgId: "org_b"; the principal above
resolved to orgId: "org_a". The target check refuses it with
target-other-organisation, whatever either agent is called.
resolveActingPrincipal(credential, lookups, door?) (./principal-by-id)
Async. Returns { ok: true, principal } or { ok: false, refusal }
(ResolveActingPrincipalResult). The principal (ActingPrincipal) is
{ principalId, orgId, kind, viaServiceAccountId?, orgRole? }, where kind is
"agent" | "person" | "service" | "fleet" (ActingPrincipalKind).
credential (ActingCredential, validated by actingCredentialSchema) is one
of three shapes, every identity field an ID:
{ kind: "agent", agentId, verifiedOrgId }— the machine path: an agent's own bearer, already verified by you (for example withvalidatePresentedBearer). The agent row's storedorgIdmust equalverifiedOrgId.{ kind: "person", personId, verifiedOrgId, verifiedOrgRole? }— the human path: a person's token, already verified by you. The person's row in that organisation is read by(personId, verifiedOrgId).verifiedOrgRole(0.12.0, optional) is the role the SAME verified token carries in that organisation (Clerk:org_role); it is copied onto the principal asorgRoleand read only byassertOrgAdmin.{ kind: "service", serviceAccountId, actingForAgentId? }— the service account. Alone it acts as itself (kind: "service", orkind: "fleet"iforgKindOfreports its organisation as"operator"). WithactingForAgentIdit acts for that agent, named BY ID and resolved within the service account's OWN organisation; the principal is the agent, andviaServiceAccountIdrecords the carrier.
The schemas are strict: a credential carrying any other key (agentName,
callerOrchestrator, name, assignedTo) is refused whole with
credential-invalid, before any lookup runs.
lookups (PrincipalLookups) are your indexed reads by ID: agentById(id),
personById(id, orgId), serviceAccountById(id) returning a PrincipalRow
({ id, orgId?, active }, schema principalRowSchema), and
organisationById(id) returning an OrganisationRow ({ id, active }, schema
organisationRowSchema), and orgKindOf(orgId) returning an OrgKind
("operator" | "client") or null. Only the lookups of the presented path
are called; orgKindOf is called on every path.
Each row must repeat the ID that was asked for, and active is required and
must be true.
Refused, each with a typed IdentityRefusal reason: a credential that does not
parse; a missing or throwing lookup (its error text is never surfaced); a miss;
a malformed, inactive or unstamped row; a row of another organisation than its
credential; an unmapped or inactive organisation; a service account acting for
an agent of another organisation. Nothing falls back to a default or master
principal.
assertTargetBelongsTo(principal, target, lookups, opts?) (./principal-by-id)
Async. Returns { ok: true } or { ok: false, refusal }
(AssertTargetResult). target (TargetIds) is { orgId?, ownerId? } read
from the STORED row; lookups (OrgKindLookups) is { orgKindOf }, the same
adapter as above, and is required (a missing or throwing adapter refuses with
principal-lookup-failed); opts (AssertTargetOptions) is
{ door?, ownerOnly?, fleetCrossOrg? }. IDs are compared byte for byte.
- The target's
orgIdmust equal the principal'sorgId(target-other-organisationotherwise). ownerOnly: truealso requiresownerIdto equalprincipalId; a target with no owner is refused (target-owner-mismatch).- A target with no
orgIdis refused (target-unstamped) to EVERY principal, the fleet principal included, with or withoutfleetCrossOrg. No right is inferred from an absence: a row with no organisation is never treated as a master row. - An absent principal, or one with an empty ID, is refused
(
credential-invalid).
assertOrgAdmin(principal, targetOrgId, opts) (./principal-by-id, 0.12.0)
Synchronous. Returns { ok: true } or { ok: false, refusal }
(AssertOrgAdminResult). opts (AssertOrgAdminOptions) is
{ adminRoles, door? }: adminRoles is your list of admin role values exactly
as the verified claim spells them (Clerk: ["org:admin"]). Decided by ID, no
lookup:
- only a
kind: "person"principal can be an organisation admin; an agent, a service account and the fleet principal are refused (principal-not-a-person); targetOrgIdmust be present (target-unstamped) and byte-equal to the principal'sorgId(target-other-organisation): an admin of A is not an admin of B;- the principal's
orgRolemust be byte-equal to an entry ofadminRoles(role-not-admin): an absent role, a member role, an empty or malformedadminRolesall refuse. No case folding, no default role; - an absent principal, or one with an empty ID, is refused
(
credential-invalid).
const who = await resolveActingPrincipal(
{ kind: "person", personId: claims.sub, verifiedOrgId: claims.org_id, verifiedOrgRole: claims.org_role },
lookups,
"owners:startBinding",
);
if (!who.ok) throw toHttpError(who.refusal);
const admin = assertOrgAdmin(who.principal, who.principal.orgId, {
adminRoles: ["org:admin"],
door: "owners:startBinding",
});
if (!admin.ok) throw toHttpError(admin.refusal);
assertPrincipalListed(principal, list, opts) (./principal-by-id, 0.13.0)
Synchronous. Returns { ok: true } or { ok: false, refusal }
(AssertPrincipalListedResult). list is a PrincipalIdList,
{ orgId, principalIds }, the stored roster of agent IDs; opts
(AssertPrincipalListedOptions) is { door? }. The single admission check
for "is this caller on that stored list", decided by agent ID only, no lookup:
- the principal's
principalIdmust be byte-equal to an entry ofprincipalIds(principal-not-listed). No case folding, no trimming, no prefix match, and no name matching anywhere: a name in the list never matches, and a principal carrying only a name has no ID (credential-invalid); - only a
kind: "agent"principal is admitted (principal-not-an-agent), an agent acting through a service account included, judged by its own ID; list.orgIdmust be present (target-unstamped) and equal the principal'sorgId(target-other-organisation);- an absent list (
list-absent) and an empty or malformedprincipalIds(list-empty) refuse; - there is no wildcard:
"*"is an ordinary string and admits nobody.
const listed = assertPrincipalListed(who.principal, { orgId: row.orgId, principalIds: row.allowedAgentIds }, {
door: "tasks:complete",
});
if (!listed.ok) throw toHttpError(listed.refusal);
An agent bound to its human owner (./principal-by-id)
An agent credential may also carry ownerPersonId: the person the agent is
bound to. It is an ID read from your VERIFIED credential row, never a request
argument. resolveActingPrincipal then proves it, and without it nothing
about the owner is read and the result is exactly what it was:
- the agent row's stored
ownerPersonIdmust be byte-equal to the one presented (agent-owner-mismatch; an absent ornullstored owner never matches); personById(ownerPersonId, orgId)must return an ACTIVE row of the same organisation, otherwiseagent-owner-not-member. The detail names the case (not found, not active, another organisation, malformed record) and never quotes your lookup's own error text;personByIdthrowing, or not supplied, is refusedprincipal-lookup-failed: the check is never skipped;- on success the principal carries
ownerPersonId, set only when proven.
PrincipalRow gains the optional ownerPersonId (string | null); ActingPrincipal
gains the optional ownerPersonId.
assertAgentOwnedBy(principal, personId, opts) (./principal-by-id)
Synchronous. Returns { ok: true } or { ok: false, refusal }; opts is
{ door? }. Admits only a kind: "agent" principal whose proven
ownerPersonId is byte-equal to personId. A person, a service account, the
fleet principal, an agent with no proven owner, an absent or empty personId
and an absent principal all refuse agent-not-owned. Types:
AssertAgentOwnedByOptions, AssertAgentOwnedByResult.
const owned = assertAgentOwnedBy(who.principal, task.requestedByPersonId, { door: "tasks:read" });
if (!owned.ok) throw toHttpError(owned.refusal);
assertRecipientAddressable(sender, recipient, lookups, opts) (./principal-by-id, 0.14.0)
Async. Returns { ok: true } or { ok: false, refusal }. recipient is
{ agentId, orgId }; lookups is { orgKindOf, rosterOf } where
rosterOf(orgId) returns the stored { orgId, principalIds } roster of that
client organisation; opts is { door? }. Decided by agent ID only:
- same organisation: admitted;
- client sender to operator recipient: the recipient's ID must be on the SENDER organisation's roster;
- operator sender to client recipient: the sender's ID must be on the RECIPIENT organisation's roster;
- client to client across organisations, a missing sender or recipient, a
sender that is not an agent, an unknown organisation kind and an
unresolvable roster all refuse. A name never matches. Types:
RecipientIds,RecipientLookups,AssertRecipientAddressableOptions,AssertRecipientAddressableResult.
const may = await assertRecipientAddressable(who.principal, { agentId: to.id, orgId: to.orgId }, {
orgKindOf, rosterOf,
}, { door: "send_message" });
if (!may.ok) throw toHttpError(may.refusal);
filterTargetsBelongingTo(principal, rows, lookups, opts) (./principal-by-id, 0.14.0)
Async. The ID-keyed replacement for scopeFilterList. Returns
{ ok: true, rows } (the rows assertTargetBelongsTo admits, in input order)
or { ok: false, refusal }. lookups and opts are those of
assertTargetBelongsTo. Type: FilterTargetsResult.
- an absent or unresolved principal is a refusal naming the door, even for an
empty list; it is never
{ ok: true, rows: [] }; - a resolved principal whose rows all belong elsewhere gets
{ ok: true, rows: [] }, so a refusal is distinguishable from an absence; - rows of other organisations, unstamped rows and a same-NAME row of another organisation are dropped; a name never decides;
- a lookup failure is a refusal of the whole call, never a silent drop.
const out = await filterTargetsBelongingTo(who.principal, rows, { orgKindOf }, { door: "tasks:list" });
if (!out.ok) throw toHttpError(out.refusal);
return out.rows;
The fleet is the operator organisation. The fleet is not a reserved ID: it
is the organisation your orgKindOf adapter reports as "operator" (for
example the organisation whose mapping row carries orgKind: "operator"),
read from your data at run time. The package spells no organisation ID. An
adapter answering null, a miss, or anything other than exactly
"operator" means "not the fleet"; a missing or throwing adapter refuses.
RULING 5: membership of the operator organisation is ordinary membership.
Its agents and persons resolve as kind: "agent" / "person" of that
organisation and reach its rows only; cross-organisation reach (kind: "fleet"
with fleetCrossOrg) belongs to the operator organisation's service account
alone, and reserved-fleet-scope refuses a CLAIM of that scope, never plain
membership:
- an agent or person credential verified for the operator organisation
resolves as an ordinary principal of it, with no cross-organisation reach;
only a service account stamped with it resolves to
kind: "fleet"; - a credential carrying
kind: "fleet"is refused (reserved-fleet-scope), whoever presents it; - any non-fleet principal reaching another organisation's row under
fleetCrossOrg: trueis refused (reserved-fleet-scope); without it,target-other-organisation; - an operator-organisation row is refused to every client principal, with or
without
fleetCrossOrg(reserved-fleet-scope); - a
kind: "fleet"principal whose organisation the adapter does not report as"operator"is refused, and so is akind: "service"principal in the operator organisation (its service account is the fleet); - the fleet principal reaches a CLIENT organisation's row only when the door
passes
fleetCrossOrg: true(a master export) AND the adapter reports that organisation as"client". An unknown organisation is refused; - the operator organisation is an ordinary stored organisation:
organisationByIdmust return it mapped and active, like any other; - a fleet row is a row stamped with the operator organisation's ID explicitly.
A row with no
orgIdis not a fleet row and is refused to the fleet principal too.
Migration (required consumer step). Before a product switches any door to
assertTargetBelongsTo, it must stamp every existing row that has no orgId:
with its real organisation ID, or with the operator organisation's ID if it
belongs to the operator's own scope. An unstamped row is refused to every caller,
including the fleet principal, so a door switched before the backfill stops
serving those rows. Run the backfill first, confirm that no unstamped row is
left in the tables the door reads, then switch the door.
viaServiceAccountId and door are not identity; no function in this module
takes a name as an identity input, and a test reads the exported signatures to
keep it that way.
A caller's standing (0.14.0)
resolveCallerStanding(credential, lookups, { adminRoles, door? }) names which
of the four callers is asking. It is built on resolveActingPrincipal and
assertOrgAdmin; it is not a second path.
Types: CallerCredential, AssertCallerStandingOptions, CallerStanding.
- A master token reaches
fleetby your mapping it to the operator service account:validateMasterBearerproves the secret, then you pass{ kind: "service", serviceAccountId }. The package never mints a fleet credential from a bearer. - A Clerk session reaches the function through
verifyClerkSessionToken: with anorg_idclaim, pass apersoncredential carryingverifiedOrgIdandverifiedOrgRole; without one, passperson-no-org. A person credential that merely lacksverifiedOrgIdis garbled and isanonymous, neverpre-org. - Branch on
standing, never on the absence of a field.pre-orgis notanonymous: refuse it where an organisation is needed, but answer it as a signed-in caller (see the refusal rules of your read doors).
Protecting an MCP server as an OAuth resource (/resource-server)
An MCP server that requires a bearer token is an OAuth 2.1 protected resource. These primitives are the whole resource side; they fetch nothing and call no network.
import {
buildProtectedResourceMetadata,
unauthorizedChallenge,
deriveClerkDiscoveryUrls,
verifyMcpAccessToken,
} from "@vantageos/cloud-identity/resource-server";
// GET /.well-known/oauth-protected-resource (RFC 9728)
const metadata = buildProtectedResourceMetadata({
resource: "https://mcp.example.com/mcp",
authorizationServers: ["https://clerk.example.com"],
scopesSupported: ["mcp:read", "mcp:write"],
});
// On every MCP request
const r = await verifyMcpAccessToken(req.headers.authorization, {
realm: "mcp",
resourceMetadataUrl: "https://mcp.example.com/.well-known/oauth-protected-resource",
issuer: "https://clerk.example.com",
audience: "https://mcp.example.com/mcp", // this resource: required
jwks: () => fetchJwks(deriveClerkDiscoveryUrls("https://clerk.example.com").jwksUrl),
});
if (!r.ok) return respond(r.status, "headers" in r ? r.headers : {});
// r.session.userId, r.session.claims
buildProtectedResourceMetadatareturns the RFC 9728 document (ProtectedResourceMetadataConfigin,ProtectedResourceMetadataout).unauthorizedChallengereturns{ status: 401, headers }whoseWWW-Authenticatealways carriesresource_metadata, pluserror,error_descriptionandscopewhen given (UnauthorizedChallengeConfig,UnauthorizedChallenge). Values are escaped; a line break throws.deriveClerkDiscoveryUrlsreturnsClerkDiscoveryUrls(discoveryUrl,jwksUrl) for anhttpsissuer.verifyMcpAccessTokenreusesverifyClerkSessionToken(RS256 only) and requires the token'saudto containaudience. It returns the verified session, or aCREDENTIAL_REFUSEDrefusal with the401envelope (McpResourceServerConfig,VerifyMcpAccessTokenResult):bearer-missingandbearer-malformedanswerinvalid_request,token-invalidanswersinvalid_token(wrong signature, issuer, audience, expiry or not-before are one answer), and an unreachable key set is503with no challenge (jwks-unavailable). A missing issuer or audience throws.- Limits, stated: the token needs a
suband akid(the Clerk session shape); the consumer fetches and caches the key set; no scope check is made on the token (readr.session.claims).
The session from the raw request, with Clerk's own handshake (/clerk-handshake, 0.19.0)
On a development instance the Clerk session lives on the hosted sign-in domain;
only Clerk's handshake brings it to your host. resolveClerkSessionFromRequest(request, { secretKey, publishableKey, authorizedParties?, jwtKey? })
is built on @clerk/backend authenticateRequest() and returns a
ClerkSessionFromRequest: signed-in (sessionToken, userId, sessionId),
handshake (status + the Location / Set-Cookie headers to return as they
are) or signed-out (reason). resumeAuthorizeFromRequest(request, input, clerk, cfg, deps)
feeds the resume path from the request and returns a ResumeFromRequestOutcome
(an AuthorizeOutcome or the handshake). Options type:
ClerkSessionFromRequestOptions. Behind a proxy, forward x-forwarded-host
and x-forwarded-proto.
A form posted back to your host (the org picker's Approve / Deny) is read with
resolveClerkSessionForFormPost(request, { ...ClerkSessionFromRequestOptions, expectedOrigin })
(0.21.2, options type ClerkSessionForFormPostOptions), never by reading a
cookie yourself: a production instance names its cookie __session_<suffix>,
and the session token is often expired by the time the person clicks. It
refuses anything but a POST whose Origin is expectedOrigin
(not-a-form-post, cross-origin-post), then resolves the session as the GET
that served the form did, refreshing an expired token; append the returned
Set-Cookie headers to your response. A post that would need a handshake is
signed-out (session-needs-handshake). Keep verifying your form's own
anti-forgery token.
From a token to { principal, orgId } (/tenant-from-token, 0.18.0)
A downstream server never reads a raw claim off a token. It hands the
Authorization header and the same config it gives verifyMcpAccessToken
to one function and gets the person and the organisation, or a typed refusal.
import { resolveTenantFromAccessToken } from "@vantageos/cloud-identity/tenant-from-token";
const t = await resolveTenantFromAccessToken(req.headers.authorization, resourceServerConfig);
if (!t.ok) return respond(t.status, "headers" in t ? t.headers : {}); // 401, 403 or 503
// t.orgId the organisation ID: scope every row with it
// t.principal.subject the verified `sub`
// t.principal.actorName "user:<sub>"
// t.principal.role the verified `org_role`, or null
- It is built on
verifyMcpAccessTokenand verifies nothing itself. A forged, expired, wrong-audience or wrong-issuer token, a missing or malformed header, and an unreachable key set are that function's answers, passed through unchanged (401with the RFC 6750 challenge, or503). No organisation and no principal is returned for a token that did not verify. - The organisation is the verified token's
org_idclaim (also read under its other spellingsorganizationIdandorgId), and it must be shaped like an organisation ID (org_...). A slug (org_slug) is a label: never read, and it never rescues a missing ID. - A verified token with no usable organisation ID is refused, never resolved
to an unscoped view:
RBAC_DENIED, reasonno-verified-organisation,status: 403. The refusal carriesdoor(from the config, defaultresolveTenantFromAccessToken) and never the token. - Types:
ResolveTenantFromAccessTokenResult,TokenPrincipal.
The standing of a resolved org scope (/scope-standing, 0.20.0)
An org-scope resolver hands every door a scope object, and doors used to branch
on its isMaster boolean. That boolean mixes "may read the whole fleet" with
"is the fleet itself". scopeStanding(scope) is the one reading:
import { requireStanding, requireFleetStanding, hasFleetRead } from "@vantageos/cloud-identity/scope-standing";
requireFleetStanding(scope, "fleet.stats"); // the fleet: service account or internal opt-in
requireStanding(scope, "tasks.list", ["fleet", "operator-admin", "member"]);
if (hasFleetRead(scope)) { /* unfiltered read: fleet or operator-admin */ }
- Fail closed. A scope is
"fleet"or"operator-admin"only whenisMaster,fleetWideand a knownmasterSourceagree; an absent or malformed scope, therefusedflag, no organisation, or any disagreement is"refused"— never the more powerful reading. requireStanding/requireFleetStandingthrowIdentityRefusalErrorRBAC_DENIEDnaming the door: reasonscope-not-resolvedfor a"refused"scope,standing-not-allowedotherwise. An emptyallowedlist is aTypeError, never a pass.hasFleetRead(scope)is true for"fleet"and"operator-admin": the reading meaning ofisMaster.- Types:
ScopeStanding,StandingScope.
The service account apart from the internal opt-in (0.20.1). "fleet"
covers two grants: the fleet's service account and an internal opt-in that
carries no credential. A door that must be the service account itself (a
verified actor, an agent credential) uses:
import { isServiceAccountScope, requireServiceAccountStanding } from "@vantageos/cloud-identity/scope-standing";
requireServiceAccountStanding(scope, "secrets.read"); // the service account only; the internal opt-in is refused
if (isServiceAccountScope(scope)) { /* ... */ }
requireServiceAccountStanding returns "fleet" (0.20.2) or throws RBAC_DENIED with scope-not-resolved
for a "refused" scope and standing-not-allowed for any other standing, the
internal opt-in included.
A verified bearer context (0.20.1). An MCP transport attaches a context to
each request after verifying its bearer: isMaster for the master population,
viaMasterBearer only when that master presented the network master bearer.
bearerStanding(context) reads it:
Types: BearerStanding, StandingBearer.
@standingGate (0.20.1). scopeStanding, hasFleetRead, requireStanding,
requireFleetStanding, isServiceAccountScope, requireServiceAccountStanding
and bearerStanding carry the JSDoc tag @standingGate in the published
declarations, so an analyser credits a call to any of them as a delegated gate
instead of keeping its own list.
From verified claims to the caller (/caller-from-claims, 0.20.0)
resolveCallerFromClaims(claims, { orgLookups, principalLookups }, { adminRoles, door })
is the one call from a VERIFIED identity's claim record to the caller. It chains
the existing primitives, deciding nothing new: resolveOrgFromClaim (the
organisation by the verified org_id; a slug never selects one), then
resolveActingPrincipal (the person as stored in that organisation), then
assertOrgAdmin (is that person an admin there).
import { resolveCallerFromClaims } from "@vantageos/cloud-identity/caller-from-claims";
const c = await resolveCallerFromClaims(identity, { orgLookups, principalLookups }, { adminRoles: ["org:admin"], door: "withOrgScope" });
if (!c.ok) {
// c.refusal: typed RBAC_DENIED (no-verified-organisation, org-mapping-not-found, other-organisation, credential-invalid, ...)
// c.claimsOrg: the claims NAME an organisation they could not prove (refuse loudly), vs none at all
// c.subject (0.20.1): the verified subject when the claims had one, e.g. to decide a service account by subject
} else {
// c.subject, c.actorName ("user:<sub>"), c.org { id, label, orgKind, ... }, c.principal, c.role, c.isOrgAdmin
}
- The role claim is read as
orgRole,org_roleororganizationRole(readOrgRoleClaim); none isnull, andnullis never an admin. claimsAnOrganisation(claims)only chooses a refusal's shape; it never names an organisation.- An empty
adminRolesis aTypeError, never a silent non-admin. - Types:
CallerFromClaimsDeps,CallerFromClaimsOptions,CallerFromClaimsResult.
From a verified agent credential to the caller and its owner
resolveCallerFromAgentCredential(credential, { orgLookups, principalLookups }, { door })
is the agent twin of resolveCallerFromClaims, exported from the package root
(AgentCallerCredential, AgentCallerDeps, AgentCallerOptions,
AgentCallerResult). The credential is { agentId, verifiedOrgId, ownerPersonId? };
a name beside the IDs refuses it credential-invalid. It reads the
organisation by ID (resolveOrgFromClaim), then the agent by ID in that
organisation, then proves the owner, all through resolveActingPrincipal.
import { resolveCallerFromAgentCredential } from "@vantageos/cloud-identity";
const c = await resolveCallerFromAgentCredential(
{ agentId: row.id, verifiedOrgId: row.orgId, ownerPersonId: row.ownerPersonId },
{ orgLookups, principalLookups },
{ door: "tasks:list" },
);
if (!c.ok) throw toHttpError(c.refusal);
// c.org, c.principal (kind "agent"), c.owner: a person principal of the same org, or null when no owner was presented
The agent behind a service-account write (/agent-writer)
The fleet service account calls a Convex mutation on behalf of an agent and
carries the claim the transport verified: the agent's stored ID and the
organisation ID it was verified in. resolveAgentWriter is the one decision on
that claim, so a door does not keep a local variant. It resolves the claim to
the stored agent principal, or throws IdentityRefusalError.
import { resolveAgentWriter } from "@vantageos/cloud-identity/agent-writer";
const writer = await resolveAgentWriter(
{ scope, verifiedActor: args.verifiedActor, targetOrgId: scope.orgId },
{ principalLookups }, // agentById, organisationById, orgKindOf: reads BY ID
{ door: "debate:postTurn" },
);
// writer = { principalId, orgId, kind: "agent" }, read from stored rows.
// The door still writes, and admits by comparing the stored IDs of the rows
// it touches with writer.principalId / writer.orgId.
It refuses (RBAC_DENIED, naming the door) when:
- the caller is not the fleet service account: a member, the internal opt-in
and an operator admin are all refused (
standing-not-allowed), an unresolved scope isscope-not-resolved; verifiedActoris absent, or is anything other than exactly{ agentId, orgId }: a name beside the IDs refuses it whole (credential-invalid);- the claim is for another organisation than
targetOrgId, or the stored agent sits in another organisation than the claim (other-organisation); - the agent lookup misses, is not supplied, throws, or returns a deactivated or malformed row; or the organisation is not active.
It only resolves: it writes nothing and grants no write. It proves the caller is
the service account and that the IDs name one live agent of that organisation;
it cannot prove the transport verified the right agent, so verifiedActor must
be the transport's own result, never a value the end client chose. Tagged
@callerResolver. Types: AgentWriterClaim, AgentWriterInput,
AgentWriterDeps, AgentWriterOptions.
Rows in a person's scope, inside the organisation (/person-scope)
Organisation isolation answers "which organisation's rows". Inside one
organisation, a person may be allowed only part of the rows, for example the
records of the portfolios they manage. filterTargetsInPersonScope applies
that narrowing. Who sees which keys is your data; the package only applies it.
import { filterTargetsInPersonScope } from "@vantageos/cloud-identity/person-scope";
const out = await filterTargetsInPersonScope(who.principal, rows, {
orgKindOf,
scopeOf: async (p) => myScopeStore.scopeFor(p.principalId), // { kind: "keys", keys: new Set([...]) } | { kind: "all", grantedBy: "role:manager" } | null
rowScopeKeys: (row) => row.portfolioId, // one key, several keys, or none
}, { door: "records:list" });
if (!out.ok) throw toHttpError(out.refusal);
return out.rows;
- Organisation isolation first:
filterTargetsBelongingToruns with the sameopts, and its refusal is returned unchanged. A row of another organisation is dropped even when its key is in the set. - A right is presented, never inferred.
scopeOfreturningnull/undefinedis refusedperson-scope-absent; an empty key set is refusedperson-scope-empty;{ kind: "all" }needs a non-emptygrantedBynaming the right, or it is refusedperson-scope-grant-unreasoned; any other form is refusedperson-scope-malformed. A"fleet"or"service"principal needs a presented scope too: there is no implicit"all". scopeOforrowScopeKeysthrowing is refusedperson-scope-lookup-failed, naming the error kind, never its message.- A row is kept when at least one of its keys is in the set. A row with no key
(
undefined,null,"",[]) is dropped; a key of any other form refuses the call. Keys are IDs, compared as exact strings: no case folding, no trimming. - Types:
PersonScope,PersonScopeDeps,PersonScopeOptions,PersonScopeResult.
An organisation is its permanent ID (0.16.0)
A label (the slug an org can be renamed to) is display text. These functions
decide "which organisation" from the permanent org ID, with the store supplied
as adapters, and refuse by default. All are in ./org-by-id and the root.
resolveOrgFromClaim(claims, lookups, opts) takes the claim record of a
VERIFIED credential (never a request field) and returns
{ ok: true, org: { id, label, allowedOrchestrators?, allowedAgentIds?, scopes, orgKind } }
or a typed RBAC_DENIED refusal. Each roster is on org only when the row
has it (since 0.24.0): allowedOrchestrators is the legacy name roster
(deprecated), allowedAgentIds the roster by agent ID. The org_id claim (also read from
organizationId and orgId, only when shaped like a Clerk org ID) selects the
mapping row through lookups.orgById; a stale label in the token is ignored,
so a rename never changes who the caller is. Refused: no claim, an ID no
mapping holds, an inactive org, a row that does not repeat the ID asked for, a
malformed row, a missing or throwing adapter.
const r = await resolveOrgFromClaim(identity, {
orgById: (id) => db.mappingByClerkOrgId(id),
}, { door: "tasks:list" });
if (!r.ok) throw new IdentityRefusalError(r.refusal);
sameOrg(a, b, opts), isFleetStamp(stamp, operator, opts) and
sameTenantStamp(a, b, operator, opts) compare OrgRef values
({ id?, label? }). Two IDs are equal or they are not; unstamped is never
a match; isFleetStamp is true for an unstamped stamp or the operator's, and
sameTenantStamp is true for the same org or when both are the fleet's.
findOperatorOrg(lookups, opts) finds the operator organisation from
lookups.activeOrganisations(limit): one, none, many, overCap
(cap defaults to 1000, the read is cap + 1) or unreadable. Only one is
an operator; pass undefined as operator otherwise and only unstamped rows
are the fleet's.
resolveOrgIdForLabelBackfillOnly(label, lookups) derives the permanent
ID of the org a label names, for a ONE-OFF BACKFILL that stamps rows written
before IDs existed. It must never be called on a request path: a request
decides its organisation from the verified org_id claim
(resolveOrgFromClaim), and a miss there is a refusal, never a retry by label.
It returns { present: true, orgId } or a typed ORG_ID_ABSENT absence
(no-label, lookup-failed, not-mapped, record-invalid, id-not-filled).
It never invents an ID.
orgMappingRowSchema is the Zod shape of a mapping row an adapter returns
(id, label, active, allowedOrchestrators?, allowedAgentIds?,
scopes, orgKind). Both rosters are optional since 0.24.0; one that is
present but malformed refuses the row.
Types: OrgRef, OrgMappingRow, OrgMappingLookups,
ResolvedOrg, ResolveOrgFromClaimOptions, ResolveOrgFromClaimResult,
OrgIdAbsence, OrgIdAbsenceReason, OrgIdResolution, OperatorOrgLookups,
FindOperatorOrgOptions, OperatorOrgResult.
No label resolution. No function above compares or selects by label, and
no option turns that on: a reference or credential without an org ID names no
organisation and is refused. A store that has not filled every ID is filled
once, with resolveOrgIdForLabelBackfillOnly, before the consumer relies on
these functions.
resolveProvisioningLabel(label, lookups) (0.20.0) is the one boundary
where an operator's provisioning input names an organisation by label. It
returns { kind: "mapped", orgId, active }, { kind: "unmapped" } (the
organisation does not exist yet), { kind: "mapped-without-id", active } or
{ kind: "unreadable", reason } (no-label, lookup-failed,
record-invalid). Every decision after it is by orgId; an unreadable store
is never "unmapped", so a failing read cannot authorise a creation. Type:
ProvisioningLabelResult.
Support access: a time-bounded, audited grant instead of membership
A person principal of th