npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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) and zod (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 dist with .d.ts.

Install

pnpm add @cogs/auth

Configuration

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, roles is never empty (it falls back to org:viewer), so it cannot signal "no membership". Use org_members/hasOrg to detect that, or pass floor: null to resolveRoles / resolveRolesFromPlugins to 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, no HS*), signature from the entry's own JWKS;
  • iss, aud, and exp/iat/sub required; iat no older than maxTokenAge (default 1h, pass options.maxTokenAge), 30 s clock tolerance;
  • a mandatory azp (falling back to client_id) on the entry's allowlist. A token with neither is rejected, and aud is never the client check;
  • ID tokens are rejected (at_hash or nonce present) and a jti is required;
  • an unknown iss is Token issuer is not trusted. A full (non-platformOnly) entry replaces the legacy single issuer/audience/jwksUrl and AUTH_DEV_SECRET (loadAuthConfig clears them, and verifyToken ignores them even on a hand-built config); only while every entry is platformOnly does 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) receives remote | fallback | unavailable transitions (no key material).
  • The writer is refreshJwksFallback from @cogs/auth/jwks-fallback-refresh. Storage is yours: implement FallbackStore (get/put by target and 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 without allowShrink, 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 unset

Call 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 guard NaN, and every comparison against NaN is false, so an unvalidated NaN horizon would disable both the staleness and skew checks with no error and no log. 3 * Number(process.env.SOMETHING) * 60_000 with the variable unset produces exactly that. A misconfigured security control should refuse to start, so it does.
  • syncedAt must carry an explicit offset (Z or ±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 formats Date.parse accepts by extension are refused for the same reason, as is a date that does not exist — Date rolls 2026-02-30 forward to March 2nd, which reads fresher than what was written.
  • An injected now is checked on every call, and a non-finite result is rejected as unusable-clock. () => Date.parse(someString) satisfies () => number and returns NaN, so neither TypeScript nor a constructor check can catch it — and a NaN "now" fails open exactly like a NaN horizon.

License

MIT