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

@latchvector/sso

v2.1.0

Published

Official Node.js SDK for Latch Vector SSO — token verification and authentication.

Readme

@latchvector/sso

Node.js SDK for Latch Vector SSO. Node 18+, ESM, TypeScript types included.

npm install @latchvector/sso

Contents

Protecting an API — the common case

Most integrations only need this. Your API verifies tokens locally; it does not call the SSO service on every request.

import express from 'express';
import { TokenVerifier } from '@latchvector/sso';
import { requireAuth, requirePermission, ssoErrorHandler } from '@latchvector/sso/express';

// Build once at startup — it caches the discovery document and signing keys.
const verifier = new TokenVerifier({
  issuer: 'https://sso.yourdomain.com',
  audience: 'https://api.yourcompany.com',   // your registered identifier
});

const app = express();

app.get('/invoices', requireAuth(verifier), (req, res) => {
  res.json({ ownerId: req.principal!.uid });
});

app.post(
  '/invoices/:id/approve',
  requireAuth(verifier),
  requirePermission('invoice.approve'),
  handler,
);

app.use(ssoErrorHandler());   // register last

Without a framework:

const principal = await verifier.verifyAuthorizationHeader(req.headers.authorization);

What audience is for

It is your application's registered identifier, and it is required — there is no option to turn the check off.

A token we issued for a different application is still validly signed by a trusted issuer. If you check the signature but not the audience, you accept it, which means you accept one from every user of every application on the platform. This is the single most common way an SSO integration is compromised, and it is why the parameter has no default.

The principal

principal.uid          // 4711 — key your records on this
principal.email        // display only, see below
principal.orgId        // 57
principal.tenantId     // 1
principal.orgPath      // "/1/57/"
principal.permissions  // ["invoice.approve"]

principal.has('invoice.approve')
principal.hasAny('invoice.approve', 'invoice.admin')
principal.hasAll('invoice.read', 'invoice.approve')
principal.canReach('/1/57/903/')   // does their granted scope cover this node?

Cache and key company data on (uid, org_id), not on uid alone. A token carries the authority of exactly one company, and a user who belongs to several can switch between them — so org_id changes while uid stays put. Anything cached under uid alone will serve one company's data while the caller is acting as another. This is the single most likely mistake in a multi-company integration.

Key your own tables on uid, never on the email. Addresses change, and a GDPR erasure request scrubs the address while uid survives. Rows keyed on email lose the link to their own user the first time either happens.

Do not cache permissions past principal.expiresAt. They are a snapshot from issue time; a revoked role takes effect on the next token, which is why access tokens last only 15 minutes.


Logging users in

Only whatever actually handles the password needs this — a login backend, a BFF, a mobile gateway. Your resource APIs do not.

import { SsoClient } from '@latchvector/sso';

const sso = new SsoClient({
  issuer: 'https://sso.yourdomain.com',
  audience: 'https://api.yourcompany.com',
});

const result = await sso.login(email, password);

if (result.status === 'mfa_required') {
  const code = await promptUserForCode();          // TOTP or recovery code
  const tokens = await sso.verifyMfa(result.pendingToken, code);
  return tokens;
}

return result.tokens;

login() returns a union, not an object with a nullable accessToken. You cannot reach a token without narrowing on status first, so the MFA branch cannot be forgotten — a customer will enable MFA eventually, and the failure mode of the nullable version is a undefined token in production.

Social login

Sign the user in with Google or Microsoft in your own frontend, then hand us the resulting ID token:

const result = await sso.socialLogin('google', googleIdToken);

The user must already exist. Accounts are provisioned by an administrator; a first-time social login for an unknown email is refused rather than silently creating an account.

Refresh

const fresh = await sso.refresh(storedRefreshToken);
await saveRefreshToken(fresh.refreshToken);   // before you use it

Refresh tokens rotate: the old one is dead the moment refresh() returns. Persist the new one first. Presenting a rotated token throws RefreshTokenReusedError, which means either you lost track of a rotation or someone else has a copy of your token. It is not retryable.

import { RefreshTokenReusedError, RefreshTokenError } from '@latchvector/sso';

try {
  return await sso.refresh(stored);
} catch (e) {
  if (e instanceof RefreshTokenReusedError) {
    await destroySession();
    await alertSecurityTeam(userId);       // this is a security event
    throw e;
  }
  if (e instanceof RefreshTokenError) {
    return redirectToLogin();              // expired or unknown — ordinary
  }
  throw e;
}

RefreshTokenReusedError is deliberately not a subclass of RefreshTokenError, so a handler that only meant to "refresh or re-login" cannot swallow a compromise signal.

Logout

await sso.logout(refreshToken);

This revokes the refresh token. The current access token stays valid for the rest of its 15 minutes — it is a signed bearer token, not a session. For immediate cut-off, have an administrator disable the account.


Machine-to-machine (API clients)

For a backend job or service that acts as itself, not a user — the OAuth2 client_credentials grant. An admin registers an API client (secret shown once) bound to an application, and the job exchanges the credentials for a short-lived token.

Protecting a route that machine callers hit — requireClient is the counterpart of requireAuth, requireScope of requirePermission:

import { requireClient, requireScope, ssoErrorHandler } from '@latchvector/sso/express';

app.post(
  '/reports/sync',
  requireClient(verifier),
  requireScope('reports.write'),
  (req, res) => {
    const { clientId, orgId, scopes } = req.client!; // ClientPrincipal
    res.json({ ok: true });
  },
);
app.use(ssoErrorHandler());

requireClient verifies with verifyClient, so a user access token is rejected here just as a machine token is rejected by requireAuth.

Calling another service (your app is the job) — obtain and cache a token:

const machine = await sso.clientCredentials(clientId, clientSecret, ['reports.write']);
await fetch(url, { headers: { authorization: `Bearer ${machine.accessToken}` } });
// machine.expiresInSeconds ~ 900; there is no refresh — cache and re-fetch on expiry.

Multitenancy (Prisma)

Verifying a token tells you who is calling; multitenancy is about what data they may touch. The SDK carries the tenant from the verified token through the async call chain and a Prisma extension applies it to every query — so a query cannot read or write another tenant's rows even if you forget the where.

import { requireAuth } from '@latchvector/sso/express';
import { bindTenant, tenantExtension } from '@latchvector/sso/tenancy';
import { PrismaClient } from '@prisma/client';

// Extend Prisma once, at startup.
const prisma = new PrismaClient().$extends(tenantExtension());

// Bind each request to its tenant, AFTER auth.
app.use(requireAuth(verifier), bindTenant());

app.get('/invoices', async (req, res) => {
  res.json(await prisma.invoice.findMany()); // only the caller's tenant
  // prisma.invoice.create({ data }) stamps tenantId automatically
});

Options (same on both bindTenant and tenantExtension):

bindTenant({ bypassPermissions: ['PLATFORM_ADMIN'] });
tenantExtension({ enabled: process.env.NODE_ENV !== 'sandbox', column: 'tenantId' });
  • Bypass — a caller with a bypassPermissions code (a platform operator) is unconstrained; an org admin stays bound to their tenant.
  • Sandbox — pass enabled: false so dev testing isn't confined.
  • No request — outside a request there is no tenant, so the extension is inert. currentTenantId() and currentTenant() expose it if you use another ORM: read the tenant and apply your own filter.

Confining to a sub-tree

tenantId is the hard wall between customers. Within one customer, an admin of a sub-org should often see only their slice of the org tree, not the whole tenant. Name the models that must be confined that way, and each is narrowed to exactly the org paths the caller's token grants:

const prisma = new PrismaClient().$extends(
  tenantExtension({ subtreeModels: ['Chart'] }),
);

A subtree model needs, alongside tenantId, an orgId and an orgPath field (a materialized path like /1/57/903/). New rows are stamped with the writer's own node; reads are confined to:

  • SUBTREE grants — the caller's node and everything below it (a left-anchored orgPath startsWith);
  • SELF grants — that node only (an exact match).

Which applies is decided by the caller's roles at token-issue time and carried in the scope_subtree / scope_self claims — you write nothing. A machine (client-credentials) token has no org reach, so a subtree model falls back to tenant-wide for it — still leak-safe across customers.

The trailing slash matters. Paths are stored /1/57/ (not /1/57), so the prefix /1/57/ can never leak into a sibling like /1/570/.

What the extension does and does not cover

The extension rewrites the Prisma model operations — findMany, findUnique, count, aggregate, groupBy, create, createMany, update, updateMany, delete, deleteMany, upsert. Anything routed through those is confined to the caller's tenant (and, for a subtree model, their org reach). Two things fall outside that boundary — treat them as your responsibility:

  • Raw queries bypass it entirely. prisma.$queryRaw and $executeRaw go straight to the database with no tenant predicate added. Never read or write a tenant-owned table over raw SQL without putting the tenant_id (and, for subtree tables, the org_path) filter in the query yourself — the extension cannot see inside a raw string.
  • Deeply-nested relation writes are not individually scoped. When you create or update one model with nested create / connect / update on a related model in the same call, only the top-level operation passes through the extension; the nested rows are neither stamped with the tenant/org columns nor filtered. For tenant-owned relations, write them as their own top-level operations (so each is stamped and scoped), or set their tenant_id / org_path explicitly in the nested payload.

Rule of thumb: touch tenant-owned tables through ordinary Prisma model calls. The moment you drop to raw SQL or lean on nested writes, the tenant wall is yours to enforce — the database's own row-level security is the backstop, so keep it enabled on those tables regardless.

Multitenancy at scale

For tables that will hold billions of rows, three columns and the right indexes keep every scoped query a range scan, never a table scan:

| Column | Type | Why | |---|---|---| | tenant_id | bigint | the hard customer wall; on every tenant-aware table | | org_id | bigint | the owning node — subtree tables only | | org_path | text | materialized path /1/57/903/, trailing slash — subtree only |

Index tenant-leading, so the tenant predicate drives the scan:

-- every tenant-aware table
CREATE INDEX ON invoices (tenant_id, created_at DESC);

-- subtree tables: prefix scans on org_path within the tenant
CREATE INDEX ON charts (tenant_id, org_path text_pattern_ops);

text_pattern_ops is what makes org_path LIKE '/1/57/%' an index range scan under any collation. For the largest tenants, partition or shard by tenant_id (Postgres declarative partitioning, or Citus/Nile-style distribution): the tenant-leading key means a query already touches only its own partition.


Errors

Every error is an SsoError with .code and .status.

| Class | Codes | |---|---| | AuthenticationError | invalid_credentials, invalid_code, invalid_id_token, invalid_token, invalid_token_use, invalid_or_expired_pending_token | | RefreshTokenError | invalid_refresh_token, refresh_token_expired | | RefreshTokenReusedError | refresh_token_reused | | AccountNotActiveError | account_not_active | | AccountLockedError | account_locked | | AccessDeniedError | access_denied | | ValidationError | validation_failed (with .fields) | | RateLimitError | too_many_requests (with .retryAfterSeconds) | | ConfigurationError | unknown_audience, discovery failures |

429 is retried automatically with exponential backoff and jitter (twice by default, maxRateLimitRetries to change it). Nothing else is retried, and error.retryable is false for everything but RateLimitError. A 403 is a decision the service already made; retrying it produces a stream of ACCESS_DENIED audit entries that a compliance officer will eventually ask you about.

access_denied does not distinguish "forbidden" from "does not exist" — telling them apart would let anyone enumerate records across tenants.


Configuration

You configure one URL. The JWKS endpoint is resolved from {issuer}/.well-known/openid-configuration and cached, so the SDK keeps working if it ever moves.

| Option | Default | | |---|---|---| | issuer | — | required | | audience | — | required, cannot be disabled | | clockToleranceSeconds | 30 | skew allowance; keep NTP running regardless | | jwksCacheMaxAgeSeconds | 600 | | | timeoutMs | 10000 | client only | | maxRateLimitRetries | 2 | client only |


Before you go live

  • [ ] audience is set to your identifier, not ours
  • [ ] Your tables key on uid, not email
  • [ ] TokenVerifier is constructed once, not per request
  • [ ] RefreshTokenReusedError is handled as a compromise, not retried
  • [ ] The mfa_required branch is implemented and tested
  • [ ] Tokens are never written to logs, URLs, or error reports

Password reset

The invite / forgot-password flow (the token comes from the emailed link or an admin-issued setup link):

await sso.forgotPassword(email);              // emails a one-time link (no account oracle)
await sso.resetPassword(token, newPassword);  // redeem the link's token

Device sessions (mobile)

A mobile app gets a longer-lived, device-bound session by passing a device at login (also on verifyMfa/socialLogin). The service returns a stable deviceId — store it in secure storage and resend it so the same device is reused. The refresh token lives far longer than the web one and slides on every use; the user can list and revoke devices via GET/DELETE /api/users/me/devices (also on the ManagementClient).

// Presence of `device` ⇒ a longer-lived, device-bound session.
const r = await sso.login(email, password, { name: "Ana's iPhone", platform: "ios" });
if (r.status === 'authenticated') {
  saveToSecureStore(r.tokens.deviceId);   // store it; resend as device.deviceId next launch
}

Management API

Everything the console does, in code — users, organizations, roles, applications, API clients, webhooks, audit, bulk import, GDPR. Authenticated with a management token (log in with audience equal to the issuer):

import { SsoClient, ManagementClient } from '@latchvector/sso';

const sso = new SsoClient({ issuer, audience: issuer });   // management token
const result = await sso.login(email, password);
if (result.status !== 'authenticated') throw new Error('MFA required');

const mgmt = new ManagementClient({ issuer, token: result.tokens.accessToken });
await mgmt.users.create({ organizationId, email, fullName });
await mgmt.roles.assign(roleId, { userId, organizationId });
await mgmt.request('POST', '/api/anything', { body }); // every endpoint, incl. new ones

→ Management API guide — every resource, the token model, and the generic request() escape hatch.

Webhooks

Get notified the moment a user's access changes — a role assigned or revoked, a role's permissions changed, an account disabled or erased — so you can clear caches or force a refresh instead of waiting for the next failed call. Every delivery is HMAC-signed and timestamped, and this SDK ships a one-call verifier.

→ Webhooks guide — events, payload, the signature scheme, and a verified handler example.

Migrating from your current system

Bring an existing estate — organizations, users, roles, permissions — across in one validated pass. Records reference each other by your own ids, bcrypt passwords carry over (everyone else is invited), and re-runs are safe.

→ Migration guide — the two-step validate/commit flow, the full payload schema, and a worked example.