@xemahq/oidc-guard
v0.6.0
Published
Framework-agnostic OIDC / JWT verification and multi-tenancy primitives — JWKS fetching, issuer-metadata resolution, signature validation, tenant-claim projection and tenant fencing.
Readme
@xemahq/oidc-guard
Layer 0 — pure, framework-agnostic OpenID-Connect JWT verification and
multi-tenancy primitives. Zero runtime dependencies (Node crypto + fetch
only); zero framework dependency; zero domain concepts.
Verification
The single, shared implementation of the error-prone bits every consumer previously copied by hand:
JwksVerifier— per-(cacheKey, kid)signing-key cache,exp/nbfchecks, optional issuer pinning, RS/PS/ES signature verification. Fail-closed.IssuerMetadataResolver— resolves a provider'sjwks_urifrom its OIDC discovery document (<base>/.well-known/openid-configuration), so no caller hard-codes a provider-specific keys path. Cached, fail-closed.IssuerTenantResolver— the one IdP-shaped seam: how anissmaps to a security domain and to a reachable discovery URL. Ships a Keycloak (…/realms/<domain>), a generic path-segment and an Entra implementation.OidcTokenVerifier— the high-level primitive: discover the JWKS endpoint for a token's security domain and verify the signature + temporal + issuer claims, returning the decoded payload.
Choosing the resolver
A deployment declares its IdP family; the selection lives in one call, so no
consumer parses iss or builds a discovery URL itself.
const resolver = createIssuerTenantResolver(
process.env.IDP_KIND, // or omit — IDP_KIND is read when nothing is passed
);
const verifier = new OidcTokenVerifier({ tenantResolver: resolver });IdpKind is a closed set — keycloak (the default) | oidc | entra |
cognito. An explicit argument outranks IDP_KIND; a blank declaration counts
as unset and yields the default; anything else unrecognised throws, naming
the set. There is no silent Keycloak fallback: an IdP nobody implemented must
fail at boot, not authenticate against the wrong issuer shape. A consumer whose
own IdP enum is wider than this set passes its value through as a string and
gets that same refusal.
For the path-segment families (oidc, cognito, entra) the tenant is the
issuer path segment at tenantPathIndex — the option outranks
IDP_TENANT_PATH_INDEX, which must be a decimal non-negative integer or the
call throws. Nothing is coerced: 0x10, 1e1 and +1 are refused rather than
quietly meaning 16, 10 and 1, and a blank value means "not configured". Keycloak
ignores the index entirely; its tenant is the /realms/<realm> suffix.
EntraIssuerTenantResolver adds the one piece of safety the generic
path-segment resolver lacks. Entra's multi-tenant meta-authorities —
ENTRA_RESERVED_TENANTS: common, organizations, consumers — appear only
in an application's configured authority, never in the iss of a concrete
token (Microsoft always mints a per-tenant issuer carrying the real directory
GUID). One in the issuer position is ambiguous, so it resolves to null and the
verifier fails closed. issuerBelongsToTenant re-derives through that refusal,
so ('…/common/v2.0', 'common') is false.
Multi-tenancy
A deployment that serves many tenants from one process derives the tenant correctly at the edge and then loses it on the way to the decision. These primitives make the two halves that get lost exist exactly once.
Vocabulary
Two dimensions, both modelled, never interchangeable (TenancyDimension):
| Dimension | What it is | Where it comes from |
| ---------------- | --------------------------------------------------------------------------------------------- | ------------------------------- |
| SecurityDomain | The IdP-level isolation boundary — a Keycloak realm, an Entra tenant, a Cognito user pool | The verified iss, and nothing else |
| Tenant | An application-level tenant/organization identifier, finer-grained than the domain | A configured token claim |
A resource can be inside the caller's security domain and still belong to a different tenant within it, so a fence always names which dimension it compares.
TenantClaimProjector — verified token ⇒ typed principal
The claim name is configuration. This package never spells any platform's claim vocabulary; a deployment declares its own once, and no call site writes a claim literal.
const projector = new TenantClaimProjector({
tenantClaim: { primaryClaim: 'tenant_id', fallbackClaims: ['legacy_tenant'] },
issuerTenantResolver: resolver, // the same one the verifier uses
});
const payload = await verifier.verify(token, verifyConfig);
// Required ⇒ TenantedPrincipal { securityDomain, subject, tenant: string }
const principal = projector.project(payload, TenantRequirement.Required);
// Optional ⇒ MaybeTenantedPrincipal { …, tenant: string | null }
const viewer = projector.project(payload, TenantRequirement.Optional);TenantRequirement is the distinction the type system enforces: only a
Required projection produces a TenantedPrincipal, and only a
TenantedPrincipal can build a tenant fence or be cross-checked. A route that
never proved a tenant cannot fence on one — it will not compile.
Fail-closed throughout, each raising a typed TenantClaimError (map to 401):
- no resolvable security domain from
iss— never a default "home" domain; - no
sub; Requiredand the configured claim is absent — never inferred from a header, a path parameter, or a similarly-named claim the config did not declare;Requiredand the deployment declared no tenant claim at all;- a claim that is present but is not a non-empty string — and a malformed primary claim refuses rather than falling through to a fallback, so a garbage claim is never indistinguishable from no claim.
reconcileSuppliedTenant — the claim is the authority
Where a caller also supplies the tenant out of band, the verified claim wins and a disagreement is a refusal. The function is shaped so the only value flowing out of it is the claim's, so wiring it in backwards still yields the safe value:
const tenant = reconcileSuppliedTenant(
principal,
request.header('X-Tenant-Id'), // untrusted
SuppliedTenantSource.Header,
); // === principal.tenant, or throwsAn absent supplied value is not a conflict; the claim simply stands.
TenantFence — one primitive for "or refuse"
Bind the caller's scope once, then apply it wherever that scope must hold — a guard, a service, a repository, a cache read.
const fence = TenantFence.forSecurityDomain(principal); // or .forTenant(principal)
// 1. Scope the QUERY — strictly stronger than checking the row afterwards,
// because it cannot be skipped on a path that locks, aggregates or deletes.
await prisma.secret.findFirst({ where: { ...fence.predicate('realmId'), id } });
// 2. Fence a lookup result — the row is unusable until it has passed through.
const subscription = fence.require(
await repo.findById(id),
(row) => row.realmId,
{ resourceKind: 'subscription' },
);
// 3. Fence a value you already hold.
fence.assert(price.realmId, { resourceKind: 'price' });Refusals raise a typed TenantAccessError (map to 403, or 404 if the
deployment prefers not to confirm existence).
A missing resource tenant is a refusal. A NULL tenant column is how
cross-tenant reads stay invisible: every hand-rolled resource.tenant !==
caller.tenant comparison passes for it. A deployment where NULL genuinely
means "shared by every tenant" opts in per call site, by name:
fence.assert(producer.realmId, {
resourceKind: 'producer',
onAbsentResourceTenant: AbsentResourceTenantPolicy.AdmitAsShared,
});The opt-in covers absence only — a row belonging to another tenant is still refused.
Refusal messages name what was refused and nothing else. They carry the
resource kind and the dimension, never an identifier — not the resource's
tenant, not the caller's, not the resource id. "Does not exist" and "exists,
not yours" produce the same text, so the fence is not an existence oracle;
they differ only in the structured code, which is for the server's log.
assertTenantMatch is the same comparison as a standalone function, for code
holding a bare scope string rather than a principal.
compositeKey / tenantScopedKey — unambiguous scoped keys
Anything partitioned per caller — a cache entry, a rate-limit bucket, a concurrency slot, an advisory-lock name, an AEAD's associated data — must carry the tenancy, and must compose it unambiguously:
`${'a'}:${'b:c'}` === `${'a:b'}:${'c'}` === 'a:b:c'Two scopes, one key; a read for one returns the other's entry. Every separator
that gets used by hand (:, |, /, ., -) occurs inside real identifiers.
tenantScopedKey(principal, 'provider-a'); // domain ␟ tenant ␟ provider-a
compositeKey(realmId ?? null, eventType); // when there is no principalThe separator is U+001F (KEY_SEGMENT_SEPARATOR), a C0 control character no
identifier contains. A segment that is empty, is not a string, or contains the
separator is refused — escaping or truncating it would reintroduce the
collision. null is the encoding for a deliberately absent segment, and is
distinguishable from every real value. The encoding is therefore injective:
distinct segment lists always produce distinct keys.
Notes
Wrapping any of this in a NestJS guard, an interceptor, an attestation check, or any other transport is the caller's job — this package holds no framework dependency, so the same primitives are usable from a guard, a middleware, a repository, or a plain function.
The tenancy primitives operate on an already-verified payload. They perform
no cryptography; verify first (OidcTokenVerifier.verify), then project.
