@cogs/auth
v0.6.0
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 (empty = unconstrained) | — |
| 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 | — |
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.
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;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, accessToken: bearerToken, orgId },
mapper,
);
return { userId: outcome.payload.sub, roles };
}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
