@cogs/auth
v0.9.1
Published
Canonical JWKS token verification + RBAC role resolution (env role-map + injected DB) for the workloom and fleetworks families
Readme
@cogs/auth
Canonical, standalone JWKS token verification + RBAC role resolution for the
workloom and fleetworks families. This package unifies the ~10 copy-pasted
packages/auth variants into one well-tested library.
- Standalone. Runtime deps are only
jose(verification/signing) andzod(config validation). - No database import. Every dynamic lookup — per-org roles, mapping rules, impersonation sessions, LDAP groups — is a caller-injected async callback.
- Config-gated providers. JWKS verify + env
ROLE_MAP_*are always available; the idp-token claim provider is on by default; the database provider activates when a lookup is injected; userinfo/supabase/ldap are opt-in. - ESM, strict TypeScript, builds to
distwith.d.ts.
Install
pnpm add @cogs/authConfiguration
loadAuthConfig() reads the environment and returns a validated AuthConfig.
Any field can be overridden explicitly (which is how you avoid mutating global
process.env in tests or multi-tenant callers).
| Env var | Purpose | Default |
| ----------------------------- | --------------------------------------------------------- | ----------------------- |
| AUTH_ISSUER | OIDC issuer; enables asymmetric JWKS verification | — |
| AUTH_JWKS_URL | Explicit JWKS endpoint (derived for /auth/v1 issuers) | — |
| AUTH_AUDIENCE | Expected aud and the accepted azp/client_id allowlist; comma-separated for multiple OIDC clients (e.g. a web + a mobile client). Empty = unconstrained. | — |
| AUTH_TRUSTED_ISSUERS | JSON array of trusted-issuer entries (see below) | — |
| AUTH_JWKS_FALLBACK, AUTH_JWKS_FALLBACK_PUBLIC_KEY | Signed last-known-good JWKS copy and its verifying key; set together | — |
| AUTH_DEV_SECRET | HS256 dev/CI secret (refused when NODE_ENV=production) | — |
| AUTH_ROLE_CLAIM | Dot-path to the role claim | roles |
| AUTH_ROLE_SCOPE | OIDC scope requested for role resolution | openid profile email |
| ROLE_MAP_ADMIN | Comma-separated glob patterns → org:admin | — |
| ROLE_MAP_CONTRIBUTOR | → org:contributor | — |
| ROLE_MAP_VIEWER | → org:viewer | — |
| ROLE_MAP_CI_AGENT | → ci:agent | — |
| AUTH_USERINFO_ENDPOINT | Override for the OIDC userinfo endpoint | ${issuer}/oidc/v1/... |
| AUTH_ROLES_CACHE_TTL_MS | userinfo role-cache TTL | 600000 |
| AUTH_ROLES_CACHE_MAX_ENTRIES| userinfo role-cache size | 500 |
| IMPERSONATION_TOKEN_SECRET | HS256 secret for impersonation tokens | — |
AUTH_AUDIENCE accepts a comma-separated list, which is how one API serves
multiple OIDC clients (a web app and a mobile app, say) with their own client
ids. For example, AUTH_AUDIENCE=rolodex_web,rolodex_mobile accepts tokens
whose aud contains either value and whose azp (or client_id, when azp
is absent) exactly matches one of them. Matching is exact-string membership
against the list, not a pattern or prefix match — rolodex_mobile_v2 does not
match an allowlist containing only rolodex_mobile. A single value (no comma)
behaves exactly as before.
Role model
type AuthRole = "org:admin" | "org:contributor" | "org:viewer" | "ci:agent";
// DEFAULT_ROLE = "org:viewer"The vocabulary is two axes, not one list:
ORG_ROLE_LADDER—org:viewer<org:contributor<org:admin. Ordered data, so a role picker or permissions matrix can iterate it instead of inventing another ranking.MACHINE_ROLES—ci:agent. Off the ladder: a service token is a different kind of principal, not a lower or higher one.
Warning: with the default floor,
rolesis never empty (it falls back toorg:viewer), so it cannot signal "no membership". Useorg_members/hasOrgto detect that, or passfloor: nulltoresolveRoles/resolveRolesFromPluginsto get[]when nothing resolves.
Comparing roles
import { hasMinimumRole, hasMinimumRoleOrMachine } from "@cogs/auth";
hasMinimumRole(user.roles, "org:contributor");
// ["org:admin"] → true (a higher rung subsumes a lower one)
// ["org:viewer"] → false
// ["ci:agent"] → false (machine principals are not on the ladder)
hasMinimumRoleOrMachine(user.roles, "org:contributor");
// ["ci:agent"] → true (machine allowance, stated at the call site)Role resolution grants the principal's single membership role and never expands
it downward, so an exact-match check (requiredRoles.includes(role)) refuses an
admin gated at org:contributor. hasMinimumRole is the comparison to use.
A minimum that is not on the ladder — today only ci:agent — has no "at or
above" relation to evaluate, so it degrades to an exact-match membership check:
only a principal literally holding ci:agent satisfies it, and org:admin does
not. An unknown minimum is likewise false; a typo'd gate refuses everyone
rather than admitting everyone.
Importing without the server-auth dependencies
The hierarchy is also published as its own entry point:
import { hasMinimumRole } from "@cogs/auth/hierarchy";It has no runtime imports at all — no jose, no zod — so a client bundle, a
shared UI-gating package, or a React Native app can gate on role without pulling
token verification in. The same symbols remain on the main entry point, so
import { hasMinimumRole } from "@cogs/auth" is unchanged.
This package is ESM-only, deliberately. It is "type": "module" with no CJS
build, and no entry point declares a require condition — the main entry and
the subpath behave the same way, so require("@cogs/auth/hierarchy") fails with
ERR_PACKAGE_PATH_NOT_EXPORTED exactly as require("@cogs/auth") does. A
require condition pointing at ESM would resolve but fail to load on Node
20.0–20.18, which is inside the supported "node": ">=20" range, so the export
map stays honest rather than advertising CJS support that does not exist.
@cogs/auth/package.json is exported, which Metro needs in order to resolve
the subpath at all.
React Native note: Metro only honours package exports when
unstable_enablePackageExports is on — the default in recent Expo SDKs, but not
older ones. On an older SDK the subpath will not resolve.
Passing roles safely
hasMinimumRole, hasMinimumRoleOrMachine and hasMachineRole return false
for a roles that is not an array, rather than throwing, so
hasMinimumRole(user?.roles, "org:admin") is safe to call while a session is
still loading. Pass the parsed array, not the raw claim string: a comma-joined
string is refused rather than substring-matched.
Verifying a token
import { loadAuthConfig, verifyToken } from "@cogs/auth";
const config = loadAuthConfig(); // from env, validated
const outcome = await verifyToken(bearerToken, config);
if (!outcome.valid) throw new Error(outcome.error);
const { sub, email } = outcome.payload;Trusted issuers (multi-issuer, strict verification)
For an application that signs users in through an OIDC IdP (Zitadel), configure one
entry per issuer instead of the single AUTH_ISSUER:
AUTH_TRUSTED_ISSUERS='[{
"kind": "zitadel",
"issuer": "https://idp.example.com",
"jwksUrl": "https://idp.example.com/oauth/v2/keys",
"audience": ["<project id>"],
"azp": ["<web client id>"],
"platformOnlyAzp": ["<operator client id>"]
}]'verifyToken peeks the unverified iss, picks the entry whose issuer matches exactly
(no normalisation: a trailing-slash mismatch fails closed), and verifies strictly:
- asymmetric algorithms only (no
none, noHS*), signature from the entry's own JWKS; iss,aud, andexp/iat/subrequired;iatno older thanmaxTokenAge(default1h, passoptions.maxTokenAge), 30 s clock tolerance;- a mandatory
azp(falling back toclient_id) on the entry's allowlist. A token with neither is rejected, andaudis never the client check; - ID tokens are rejected (
at_hashornoncepresent) and ajtiis required; - an unknown
issisToken issuer is not trusted. A full (non-platformOnly) entry replaces the legacy singleissuer/audience/jwksUrlandAUTH_DEV_SECRET(loadAuthConfigclears them, andverifyTokenignores them even on a hand-built config); only while every entry isplatformOnlydoes the legacy issuer keep running alongside. The dev HS256 path is unreachable while any entry is configured.
The schema is strict: at least one azp or platformOnlyAzp; no client id in both; a
platformOnly entry lists only platformOnlyAzp; unique issuers; kind: 'sts' is rejected
(STS tokens use @catesworks/sts). A bad value throws at loadAuthConfig.
VerifyResult carries kind ('legacy' | 'zitadel') and platformOnly. A
platformOnlyAzp client (an admin app, an operator machine user) verifies but is valid for
operator routes only; refuseOnApi(result) returns the 401 detail for ordinary routes.
checkAzp is exported for custom checks. (The lenient single-issuer azp behaviour for
Supabase-shaped tokens is unchanged.)
Roles never come from a trusted-issuer token
An IdP-asserted role claim must not uplift a user. For kind !== 'legacy' tokens resolve
roles from your database only:
// pass the verify result's kind and the split is made for you:
const providers = createRoleProvidersForToken(result.kind, toggles, { dbLookup })
const roles = await resolveRolesFromPlugins(providers, { userId, accessToken, orgId }, mapper)
// or, without plugins:
await resolveRoles({ tokenPayload, userId, orgId, dbLookup, tokenKind: result.kind })createTrustedIssuerRoleProviders(dbLookup) (DatabasePlugin only) and
trustTokenClaims: false are the building blocks. The plain createRoleProviders() /
resolveRoles() defaults still read token claims: do not use them for kind !== 'legacy'.
JWKS last-known-good fallback
If the IdP's JWKS endpoint is unreachable, a signed copy keeps verification running. It is
consulted only when the endpoint cannot be read (network error, timeout, non-200), never
on a signature, kid or claim failure, so every other check still applies. Set
AUTH_JWKS_FALLBACK (a compact ES256 JWS, typ: jwks-fallback+jwt, payload
{iss, iat, jwks}) and AUTH_JWKS_FALLBACK_PUBLIC_KEY (the public ES256 JWK that verifies it)
together: setting one throws at loadAuthConfig. The copy must be signed for the entry's
exact iss, carry only public signing keys with a kid, and be at most 7 days old (a future
iat beyond 30 s is rejected); a kid-less token is rejected. Any problem rejects with
JWKS fallback copy rejected: ….
checkJwksFallback(config.trustedIssuers)is a boot check; it never throws.setJwksFallbackReader(reader)swaps the env source for a live store;setJwksFallbackListener(fn)receivesremote | fallback | unavailabletransitions (no key material).- The writer is
refreshJwksFallbackfrom@cogs/auth/jwks-fallback-refresh. Storage is yours: implementFallbackStore(get/putbytargetand variable) over SSM, a secret manager or files, run it daily. It validates the live set, signs{iss, iat, jwks}, refuses to write unless every target's stored public key equals the signing key's (the trust anchor is set by hand and never written by the job), refuses a smaller key set withoutallowShrink, and reads and checks everything before the first write. - Applies to
kind: 'zitadel'entries only.
Refuse to boot misconfigured
import { assertProductionAuthEnv } from '@cogs/auth'
assertProductionAuthEnv() // throws unless NODE_ENV=production and AUTH_DEV_SECRET is unsetCall it first at process start. Messages name keys only, never values.
Hono middleware (@cogs/auth/hono)
hono is an optional peer dependency; the package root never imports it.
import { createAuthMiddleware } from '@cogs/auth/hono'
app.use('/api/*', createAuthMiddleware({
resolveIdentity: async (result, token, c) => { /* (issuer, sub) -> your user */ },
resolveRoles: async (result, identity) => /* DB only for result.kind !== 'legacy' */,
}))It extracts the bearer, verifies, refuses platformOnly tokens (override with refuse),
resolves identity and roles through your callbacks, and stores the user on the context
(variable, default 'user'). A valid token whose identity cannot be resolved answers
401/403/503, never anonymous; optional: true lets a missing or invalid bearer continue
anonymously. handlePrefixed('wl_', handler) routes app-specific token prefixes (PATs,
impersonation tokens) to their own verification and never falls through to JWT on failure;
createAuthMiddleware skips a request a prefixed handler already authenticated.
The (issuer, sub) → user mapping is app data and stays yours.
Userinfo hardening
fetchUserinfo never follows a redirect (the bearer goes to the endpoint and nowhere else),
times out after 5 s, and binds sub. With trustedIssuers configured it refuses to derive an
endpoint (that would be the legacy issuer's): pass endpoint: userinfoUrl(<the verified token's iss>). Pass { requireEmailVerified: true } on a first login
that provisions from the email: the response is refused unless email_verified === true and
email is non-empty. { endpoint } overrides the URL; userinfoUrl(issuer) builds it.
Wiring role resolution with an injected DB lookup
The package never imports your database. You pass an async lookup; only the providers you enable actually run.
import {
loadAuthConfig,
verifyToken,
RoleMappingService,
createRoleProviders,
resolveRolesFromPlugins,
} from "@cogs/auth";
const config = loadAuthConfig();
// 1. Your database access, injected as plain async callbacks.
const dbLookup = async (userId: string, orgId: string) =>
(await db.membership.findRole(userId, orgId)) ?? null; // string | null
const mapper = new RoleMappingService({
config,
// Optional dynamic per-org rules; DB rules override env ROLE_MAP_*.
dbLoader: async (orgId) => db.roleRules.forOrg(orgId),
});
// 2. Enabled providers: idp-token (default) + database (because dbLookup given).
const providers = createRoleProviders(
{ userinfo: false, supabase: false },
{ config, dbLookup },
);
// 3. On each request: verify, then resolve roles.
async function authorize(bearerToken: string, orgId: string) {
const outcome = await verifyToken(bearerToken, config);
if (!outcome.valid) throw new Error(outcome.error);
const roles = await resolveRolesFromPlugins(
providers,
{
userId: outcome.payload.sub,
sub: outcome.payload.sub, // enables the userinfo `sub` check
accessToken: bearerToken,
orgId,
},
mapper,
);
return { userId: outcome.payload.sub, roles };
}userId is your own user identifier (it may be an internal DB id); pass the
verified token's sub separately as sub: payload.sub. When sub is set,
UserinfoPlugin rejects a userinfo response for any other subject; when it is
omitted the check is skipped.
For a single-source resolution (token claims → injected DB → default) without
the plugin pipeline, use resolveRoles({ tokenPayload, userId, orgId, dbLookup }).
Impersonation (optional)
Short-lived HS256 staff-acting-as-user tokens, signed with a dedicated secret.
Inert unless IMPERSONATION_TOKEN_SECRET is configured. The live session
re-check is another injected lookup — omit it for crypto-only verification.
import { signImpersonationToken, verifyImpersonationToken } from "@cogs/auth";
const token = await signImpersonationToken(
{ actAs: userId, staffId, impersonationSessionId },
new Date(Date.now() + 10 * 60_000),
config,
);
const result = await verifyImpersonationToken(token, {
config,
lookup: async (sessionId) => ({
session: await db.impersonationSessions.find(sessionId),
org: await db.orgs.find(targetOrgId),
}),
});Providers
| Provider | Default | Injected dependency |
| ------------------ | ------- | ----------------------------------- |
| idp-token | on | — (decodes verified token) |
| database | on* | dbLookup |
| userinfo | off | fetch (overridable) |
| supabase | off | — (reads app_metadata) |
| fleetworks-claim | off | fleetworksClaimOptions (optional) |
| ldap | off | LdapClient |
* on automatically when a dbLookup is provided.
fleetworks-claim
Reads the whole rolodex-owned app_metadata.fleetworks subtree — v, source,
and syncedAt alongside the roles — and yields nothing unless all of them hold.
supabase cannot do this: it is one dot-path in, strings out, so staleness is
unenforceable through it and pointing it at the subtree would emit the subtree's
keys as group names.
const providers = createRoleProviders(
{ idpToken: false, fleetworksClaim: true },
{
config,
dbLookup,
fleetworksClaimOptions: {
// Default 3h = 3x the hourly rolodex reconcile period.
maxAgeMs: 3 * 60 * 60 * 1000,
// How far ahead of us a `syncedAt` may sit before it is a broken
// writer clock rather than skew. Default 5m.
maxClockSkewMs: 5 * 60 * 1000,
onReject: (reason, detail) => log.warn({ reason, detail }),
},
},
);A rejected subtree returns no roles rather than flooring the principal, so a
locally-granted role survives a rolodex outage. The rejection reason is the
only thing that separates "rolodex went stale" from "this user genuinely has no
roles"; route it somewhere you will see it.
Three things fail loudly rather than quietly:
- The duration options are validated at construction and throw.
??does not guardNaN, and every comparison againstNaNis false, so an unvalidatedNaNhorizon would disable both the staleness and skew checks with no error and no log.3 * Number(process.env.SOMETHING) * 60_000with the variable unset produces exactly that. A misconfigured security control should refuse to start, so it does. syncedAtmust carry an explicit offset (Zor±HH:MM). ECMA-262 parses an unzoned date-time as reader-local time, so in a negative-offset zone a stale subtree can read as fresh. Legacy formatsDate.parseaccepts by extension are refused for the same reason, as is a date that does not exist —Daterolls2026-02-30forward to March 2nd, which reads fresher than what was written.- An injected
nowis checked on every call, and a non-finite result is rejected asunusable-clock.() => Date.parse(someString)satisfies() => numberand returnsNaN, so neither TypeScript nor a constructor check can catch it — and aNaN"now" fails open exactly like aNaNhorizon.
License
MIT
