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.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) 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 (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_LADDERorg:viewer < org:contributor < org:admin. Ordered data, so a role picker or permissions matrix can iterate it instead of inventing another ranking.
  • MACHINE_ROLESci: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 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