@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-clientUsage
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 thanmaxStaleMsago 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 noinvalidateran 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
AuthUnavailableErrorwithout contacting the issuer. The client secret is only sent to anhttpsintrospection endpoint (plainhttponly onlocalhost,127.0.0.1or[::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
AuthUnavailableErrorwhen the issuer can't answer, nevernullas if the person didn't exist. A live directory copy keeps its last state, markedstale, 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 objectWhat 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 plainhttponly onlocalhost,127.0.0.1or[::1], the same rule as the issuer; cached for 10 minutes and fetched again once when a token names an unknownkid, 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).noneand the HMAC algorithms (HS256and so on) are never accepted, and symmetric keys in the JWKS are ignored; issis your configured issuer,audis your client id,iatis within 5 minutes of your clock either way,exphas not passed (logout tokens must have one; for security events it's checked when present), andjtiis present;typis exactlylogout+jwtfor logout tokens andsecevent+jwtfor security events;- a logout token has the back-channel logout event, a
sub, and nononce; a security event has a subject (sub_id, orsub; aniss_subsubject 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-Afterheader 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: callflush()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 usabledirectory_endpoint(it may come from such a server: discovery is then asked again, at most once a minute). Nevernullfor any of these. A cached answer that hasn't expired is still returned.DirectoryForbiddenError(configuration, retrying won't help), withcode:NO_DIRECTORY_GRANT(your app may see nobody),NO_EMAIL_GRANT(personByEmailwithout the email grant),UNAUTHORIZED(client credentials refused) orFORBIDDEN(any other 403 with the issuer's JSON error). A 401 or 403 without the issuer's JSON error (a proxy's page, say) isAuthUnavailableError.membersthrowsAuthUnavailableErrorif 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()andmembers()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/peoplemetadata 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.removedfor everyone no longer in it,person.updatedfor changed cards,person.addedfor new ones, thenresynced. 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 withDirectoryForbiddenErrorwhen the issuer refuses the credentials or your app has no grant, and with aTypeErrorwhen the runtime'sWebSocketcan't send request headers (browsers, Node.js before 22). Pass asignalto give up after a while. - Disconnected: the last copy stays readable,
staleis true,errorgets anAuthUnavailableError, 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,
errorgets aDirectoryForbiddenErrorand 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
