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

@bitfocusas/auth-client

v0.2.0

Published

OAuth 2.0 Token Introspection (RFC 7662) client with fail-closed checks, plus verified OIDC Back-Channel Logout and Security Event Token (RFC 8417) receivers

Readme

@bitfocusas/auth-client

A small client for OAuth 2.0 Token Introspection (RFC 7662) that fails closed. Your API asks the authorization server whether a bearer token is still active, so a sign-out, a revoked session or a disabled account takes effect on the next request instead of when the token expires.

It also receives the issuer's pushed events, verified before anything acts on them: OpenID Connect Back-Channel Logout 1.0 logout tokens and Security Event Tokens (RFC 8417), so a sign-out or a disabled account reaches your app within seconds. And it reports what people did in your app to the issuer's audit endpoint, batched and retried in the background, and looks up the people and groups the issuer lets your app see, on request or as a live copy kept in memory.

It works with any issuer that publishes an OpenID Connect discovery document (<issuer>/.well-known/openid-configuration) listing an introspection_endpoint (and a jwks_uri for events). Its one runtime dependency is jose. It runs on Node.js 20.19+ / 22.12+ and edge runtimes (it uses fetch and Web Crypto), and ships ESM and CommonJS builds (the CommonJS build loads jose, which is ESM-only, through require() of ES modules, hence those Node versions). The live directory copy needs a WebSocket that can send request headers: Node.js 22.12+ has one built in.

Install

npm install @bitfocusas/auth-client

Usage

import { createAuthClient } from '@bitfocusas/auth-client'

const auth = createAuthClient({
	issuer: 'https://auth.example.com',
	clientId: process.env.AUTH_CLIENT_ID!,
	clientSecret: process.env.AUTH_CLIENT_SECRET!,
})

// A read: may use a cached answer for up to 10 seconds.
const user = await auth.requireActive(token)

// A write: always asks the issuer, never uses the cache.
const writer = await auth.requireActive(token, { mutation: true })
// → { sub, sid?, scope: string[], groups: string[], clientId, exp }

requireActive throws InactiveTokenError when the token isn't active (answer 401) and AuthUnavailableError when the issuer couldn't confirm it (answer 503). It only accepts access tokens: an active refresh token counts as inactive.

introspect(token) returns the raw RFC 7662 response, is never cached, and throws AuthUnavailableError on failure.

Express

import express from 'express'
import { createAuthClient, requireAuth } from '@bitfocusas/auth-client'

const app = express()
const auth = createAuthClient({ issuer: 'https://auth.example.com', clientId: '…', clientSecret: '…' })

app.use('/api', requireAuth(auth))

app.post('/api/things', (req, res) => {
	res.json({ owner: req.auth.sub })
})

The middleware reads Authorization: Bearer <token>, sets req.auth on success, answers 401 for a missing or inactive token and 503 when the issuer is unavailable. Other errors go to next(error). It works with Express 4 and 5 and doesn't depend on Express.

By default every method except GET, HEAD and OPTIONS counts as a mutation. Override it with requireAuth(auth, { mutation: true }) or a function of the request:

import type { Request } from 'express'

app.use('/api', requireAuth(auth, { mutation: (req: Request) => req.path.startsWith('/admin') }))

Anything other than false from the function, or an exception, counts as a mutation.

When the issuer is unavailable the 503 carries Retry-After: 5.

For TypeScript, declare req.auth once in your app:

import type { ActiveToken } from '@bitfocusas/auth-client'

declare global {
	namespace Express {
		interface Request {
			auth: ActiveToken
		}
	}
}

Options

| Option | Default | Meaning | | -------------- | -------------- | ----------------------------------------------------------------------------------------- | | issuer | required | The issuer identifier. Must be https; plain http only for localhost, 127.0.0.1 or [::1]. Discovery must return the same issuer. | | clientId | required | Sent with HTTP Basic authentication to the introspection endpoint. | | clientSecret | required | | | timeoutMs | 2000 | Per request to the issuer, discovery included. No retries. | | cacheMs | 10000 | How long a read may reuse an answer. Mutations never do. 0 turns the read cache off. | | maxStaleMs | 60000 | How long a read may fall back to the last positive answer while the issuer is down. 0 fails closed. | | fetch | global fetch | A custom fetch implementation. | | audit | see below | Batching, queue size and a drop callback for audit reports. | | directory | see below | How long directory answers are reused. |

Failure behaviour

A failure never grants access.

  • Mutations always ask the issuer. A timeout, a network error, a non-200 answer, malformed JSON or a failed discovery throws AuthUnavailableError.
  • Reads use a cached answer younger than cacheMs. After that they ask the issuer. If the issuer can't answer, the last answer for that token is used only if it was positive, its request started less than maxStaleMs ago and the token hasn't expired. Answers that arrive out of order can't undo a negative one: a negative answer always replaces a cached positive, and a positive is only cached if no newer answer is there and no invalidate ran while it was in flight.
  • Discovery is read once and kept for an hour. If a later refresh fails, the last known endpoint stays in use. After a failed attempt, discovery is tried again a minute later at the earliest; until a first one succeeds, calls in between throw AuthUnavailableError without contacting the issuer. The client secret is only sent to an https introspection endpoint (plain http only on localhost, 127.0.0.1 or [::1]), and redirects aren't followed. Responses larger than 1 MB are refused.
  • Caching: in memory, per process, keyed by a SHA-256 hash of the token. The token itself is never stored.
  • Logging: none. Error messages name the reason only, never the token, the secret or a response body.
  • Events never grant anything: they only drop cached answers and call your handlers. A lost event is caught up on by the next introspection (writes always introspect).
  • Directory lookups throw AuthUnavailableError when the issuer can't answer, never null as if the person didn't exist. A live directory copy keeps its last state, marked stale, while it reconnects. See Directory lookups and Live copy.

Invalidating the cache

The event handlers below do this for every verified event. When your app learns some other way that a user or session has ended, drop the cached answers so the next read asks the issuer again:

auth.invalidate({ sub: 'user-id' })
auth.invalidate({ sid: 'session-id' })

Events: back-channel logout and security events

Register two URLs with the issuer: a back-channel logout URI and a security events URI. One handler serves both; it tells them apart by content type (application/x-www-form-urlencoded with a logout_token, or application/secevent+jwt).

const handlers = {
	// OIDC Back-Channel Logout: end your own session(s) for this person or session.
	onLogout: async ({ sub, sid }) => { await sessions.endFor({ sub, sid }) },
	onAccountDisabled: async ({ sub }) => { await sessions.endFor({ sub }) },
	onAccountEnabled: async ({ sub }) => {},
	onSessionRevoked: async ({ sub, sid }) => { await sessions.endFor({ sub, sid }) },
	onCredentialChange: async ({ sub, credentialType, changeType }) => {},
	// Any other event type, custom ones included: its type URI and its members as sent.
	onEvent: async ({ type, sub, payload }) => {},
}

An issuer's "test event" (sent to check that the URL works) is an event type the library doesn't know, so it arrives at onEvent, with sub set to test.

Express

app.post('/auth/backchannel-logout', auth.events.express(handlers))
app.post('/auth/events', auth.events.express(handlers))

No body parser is needed: the handler reads at most 64 KB of the request itself. If express.urlencoded(), express.text() or express.raw() ran first, it uses that body instead.

Next.js (App Router)

// app/api/auth/backchannel-logout/route.ts (and the same in app/api/auth/events/route.ts)
import { auth } from '@/lib/auth'

export const POST = auth.events.next(handlers)

auth.events.next returns a (req: Request) => Promise<Response>, so it works with any Fetch API server too.

Anything else

const handle = auth.events.handler(handlers)
const { status } = await handle({ headers: req.headers, rawBody }) // or body: a string, bytes or a parsed form object

What is checked

Every token is verified before anything in it is read or acted on:

  • the signature, against the issuer's JWKS (from discovery's jwks_uri: https, or plain http only on localhost, 127.0.0.1 or [::1], the same rule as the issuer; cached for 10 minutes and fetched again once when a token names an unknown kid, at most once every 30 seconds, at most 32 keys kept);
  • the algorithm, which must be one the issuer advertises in id_token_signing_alg_values_supported (RS256 if it doesn't say). none and the HMAC algorithms (HS256 and so on) are never accepted, and symmetric keys in the JWKS are ignored;
  • iss is your configured issuer, aud is your client id, iat is within 5 minutes of your clock either way, exp has not passed (logout tokens must have one; for security events it's checked when present), and jti is present;
  • typ is exactly logout+jwt for logout tokens and secevent+jwt for security events;
  • a logout token has the back-channel logout event, a sub, and no nonce; a security event has a subject (sub_id, or sub; an iss_sub subject must name your issuer) and at least one event.

Then, before your handler runs, cached introspection answers for that sub and sid are dropped, so the next requireActive asks the issuer again even for reads.

| Answer | When | | ------ | ---- | | 200 | A logout token was handled. | | 202 | A security event was accepted. | | 400 | The request isn't a valid event (bad signature, claims, type or body, or over 64 KB). Nothing ran. | | 500 | Your handler threw. The issuer retries. | | 503 | The issuer's discovery or keys couldn't be loaded, so nothing could be verified. The issuer retries. |

Issuers retry events with the same jti. An event whose handler succeeded is remembered for 10 minutes (at most 10,000 ids, in memory): a retry or a replay of it is acknowledged without running the handler again. A handler that threw isn't remembered, so the retry runs it. Keep handlers idempotent anyway: the memory is per process. Nothing is logged, ever: not the tokens, not their contents.

Audit reports

Tell the issuer what people did in your app, so it shows up in their history next to sign-ins and other events. The issuer must publish an audit_endpoint in its discovery document (a custom key); events are POSTed there as JSON with your client credentials (HTTP Basic), the same https rule as introspection.

auth.audit.report({
	sub: user.sub, // as the issuer's tokens name the person
	action: 'invoice.paid', // a-z, 0-9, '.', '_', '-'; at most 64 characters
	target: 'Invoice 2026-104', // optional free text
	details: { amount: 100, currency: 'EUR' }, // optional JSON object
	// id: your own event id (default: a random UUID); at: a Date or ISO string (default: now)
})

// On shutdown, send what's still queued:
process.on('SIGTERM', async () => {
	await auth.audit.flush()
	process.exit(0)
})

report returns at once: it never throws, never waits for the network and never blocks a request. Events are queued in memory and sent in batches. The issuer stores each event once per id, so a retried batch never stores an event twice; pass your own id if you might report the same thing again (after a restart, say).

| Option (audit: { … }) | Default | Meaning | | ----------------------- | ------- | ------- | | batchSize | 100 | Events per request (1–100). A full batch is sent at once. | | flushIntervalMs | 1000 | How long an event may wait for others to share its request. | | maxQueue | 10000 | Events held in memory. When full, the oldest waiting one is dropped. | | onDrop | none | (id, reason) => void, for every event that won't be stored. |

const auth = createAuthClient({
	issuer: 'https://auth.example.com',
	clientId: '…',
	clientSecret: '…',
	audit: { onDrop: (id, reason) => console.warn(`audit event ${id} dropped: ${reason}`) },
})

Each event is checked before it's queued, against the same limits the issuer applies: sub 1–256 characters, id 1–128, target and details together at most 2 KB as JSON and nested at most 10 deep, at within the last 30 days and at most 5 minutes ahead. A request never carries more than 100 events or 256 KB.

onDrop gets the event's id (an empty string if the event wasn't an object) and one of these reasons, never the event's contents:

| Reason | When | | ------ | ---- | | invalid, too_large, out_of_range | The event breaks a limit above (found before sending, or by the issuer). | | unknown_subject (or another issuer reason) | The issuer rejected that one event, e.g. the person never used your app. A result the client can't match to its event counts as invalid. | | queue_full | maxQueue events were already waiting. | | unsupported | The issuer's discovery document has no usable audit_endpoint. | | invalid_request | The issuer refused the request as a whole (400). |

Failures never reach your app:

  • 5xx, 429, network errors, timeouts (10 s) and a 200 that isn't the expected JSON (for example an HTML page from a server that doesn't have the endpoint yet): the whole batch is retried with exponential backoff, 1 s doubling up to an hour, jittered. A Retry-After header is a minimum: the wait is never shorter than it, nor than the backoff (at most an hour).
  • 401 or 404 (wrong credentials, or the issuer switched reporting off): sending pauses and is retried every 5 to 10 minutes at least, never in a tight loop. Events keep queueing up to maxQueue.
  • 413: the batch is split; a single event the issuer still finds too large is dropped as too_large.
  • Timers are unref'd, so a queue never keeps your process alive: call flush() before exiting. flush() sends the events queued when it's called, ignoring the interval and any backoff, and resolves when they're sent or an attempt failed (what's left stays queued). Events reported meanwhile wait for the normal timer. It never rejects.
  • Nothing is logged: not the events, not the answers.

Directory lookups

Look up the people and groups the issuer's admins let your app see, with your client credentials (HTTP Basic, the same https rule as introspection). The issuer must publish a directory_endpoint in its discovery document (a custom key).

const person = await auth.directory.person(sub) // Card | null
const people = await auth.directory.people(ids) // Map<id, Card>: the ones your app may see
const byEmail = await auth.directory.personByEmail('[email protected]') // Card | null (needs the email grant)
const groups = await auth.directory.groups() // DirectoryGroup[]
const group = await auth.directory.group(groupId) // DirectoryGroup | null
for await (const member of auth.directory.members(groupId)) {
	// every member, fetched 200 at a time
}

A card's id is the person's sub. Its status is active, deactivated or expired (treat any other value as not active), and groups lists the groups your app may see. name, email (a verified address, or null), handle, avatar (a picture URL, or null), accountExpiresAt (ms since the epoch, or null) and metadata appear only when your app is granted them: a field it isn't granted is absent, not null. Groups have id, slug, name and, when granted, metadata.

null means the person or group doesn't exist or your app may not see it; the issuer doesn't say which. people asks for at most 100 ids per request and leaves out the ones it can't return. members yields nothing for a group your app can't see.

| Option (directory: { … }) | Default | Meaning | | --------------------------- | ------- | ------- | | cacheMs | 60000 | How long a found person or group, and the group list, is reused. 0 turns it off. | | notFoundCacheMs | 10000 | How long "not found" is reused. 0 turns it off. |

Logout and security events for a sub, and auth.invalidate({ sub }), drop that person's cached answers.

Failures:

  • AuthUnavailableError (try again later): 5xx, 429, network errors, timeouts, a failed discovery, an answer that isn't the expected JSON, and any 404 that isn't the issuer's own "not found" (the directory switched off, or a server that doesn't have the endpoint yet during an upgrade), and a discovery document without a usable directory_endpoint (it may come from such a server: discovery is then asked again, at most once a minute). Never null for any of these. A cached answer that hasn't expired is still returned.
  • DirectoryForbiddenError (configuration, retrying won't help), with code: NO_DIRECTORY_GRANT (your app may see nobody), NO_EMAIL_GRANT (personByEmail without the email grant), UNAUTHORIZED (client credentials refused) or FORBIDDEN (any other 403 with the issuer's JSON error). A 401 or 403 without the issuer's JSON error (a proxy's page, say) is AuthUnavailableError.
  • members throws AuthUnavailableError if the group stops being found after the first page, rather than passing off part of the list as all of it.
  • Lookups are never retried internally. Nothing is logged, and error messages never quote a response.

Live copy

auth.directory.live() keeps everything your app may see in memory, current within a second or so, over the issuer's stream (wss://<issuer host>/stream/v1, client credentials in an Authorization: Basic header). Reads are synchronous; events tell you what changed.

const dir = await auth.directory.live() // resolves once the first copy has arrived

dir.person(sub) // Card | undefined
dir.personByEmail('[email protected]') // Card | undefined; any case; the card's address only; needs the email grant
dir.people() // Card[]
dir.members(groupId) // Card[]; empty for a group your app can't see
dir.groups() // DirectoryGroup[]
dir.group(groupId) // DirectoryGroup | undefined

const off = dir.on('person.added', (card) => {})
dir.on('person.updated', (card) => {}) // the full new card
dir.on('person.removed', (id) => {}) // left view, or was deleted
dir.on('membership.added', ({ personId, groupId, via, expiresAt }) => {})
dir.on('membership.removed', ({ personId, groupId, via, reason }) => {})
dir.on('group.updated', (group) => {}) // also updated on every card
dir.on('group.removed', (id) => {}) // also removed from every card
dir.on('resynced', () => {}) // a fresh copy replaced the old one
dir.on('error', (error) => {}) // a connection failed; it reconnects by itself

dir.ready // true once the first copy arrived, until close()
dir.stale // true while the copy may be out of date
dir.version // the issuer's change position, for diagnostics

await dir.close()
  • Handlers run after the copy has changed. A handler that throws (or rejects) is ignored; the copy carries on.
  • Cards and groups handed out are frozen.
  • While a live copy is open, ready and not stale, auth.directory.person(), people(), personByEmail(), groups(), group() and members() answer from it without a request. Otherwise they ask the issuer as usual. personByEmail() asks the issuer when no card in the copy has the address: the copy knows only each card's one address, while the issuer matches any of the person's verified addresses.
  • person/people metadata values on cards only list people your app can see, checked when the card is sent (an update to it, or a fresh copy). A card isn't re-sent when someone it refers to comes into or leaves view.
  • People your app sees only because they used it (appUsers) stay in the copy after the issuer forgets that use (retention), until their next update or a fresh copy.
  • When the issuer sends a fresh copy (after a reconnect, after a message the copy couldn't apply, or when an admin changed what your app may see), it replaces the old one and you get person.removed for everyone no longer in it, person.updated for changed cards, person.added for new ones, then resynced.
  • live({ signal }): aborting stops waiting for the first copy (live() rejects with the abort reason) and closes the copy.

Live copy failures:

  • Until the first copy arrives, live() keeps trying with backoff. It rejects with DirectoryForbiddenError when the issuer refuses the credentials or your app has no grant, and with a TypeError when the runtime's WebSocket can't send request headers (browsers, Node.js before 22). Pass a signal to give up after a while.
  • Disconnected: the last copy stays readable, stale is true, error gets an AuthUnavailableError, and it reconnects after 1 s, doubling up to 60 s, each with jitter. On reconnect it takes a fresh copy (see above). The backoff starts over at 1 s only once a connection has stayed up for 30 s after its copy arrived.
  • Refused: a WebSocket doesn't show the HTTP status of a refused connection, so the client then asks the REST API once whether the credentials and grant are good. If they aren't, error gets a DirectoryForbiddenError and it tries again only every 30–60 s. If they are (the issuer is at its connection limit, or the app asked for too many copies in a short time), it waits at least 5 s.
  • Silent for 45 s (the issuer sends a heartbeat every 15 s): the connection is dropped and replaced.
  • A message it can't apply makes it ask for a fresh copy: one request at a time, the first at once, then (if the new copy can't be used either, or none came within 10 s) after 1 s, doubling to 60 s, never sooner than 5 s after the last. A message type it doesn't know is ignored.
  • Nothing is logged.

License

MIT