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

@authris/sdk

v3.0.3

Published

Authris SDK — OIDC RP helpers (authorize, exchange, refresh, end-session, verify), Device Flow + PAT for CLIs, Service Account (private_key_jwt) M2M client

Readme

@authris/sdk

Official TypeScript SDK for Authris. Three complementary roles:

  • OIDC RP helpers — drive end-user sign-in: build the /authorize URL with PKCE + state + nonce, exchange the code, verify tokens, refresh, RP-Initiated logout.
  • Device Flow + PAT for CLIs — RFC 8628 device authorization plus long-lived Personal Access Tokens, with the same wire shape as access_tokens.
  • Service Accounts (M2M)ServiceAccountClient signs private_key_jwt client assertions (RFC 7521/7523) and mints + caches short-lived client_credentials access tokens for backend-to-backend calls.
  • M2M Management APIM2mManagementClient provisions M2M resources, service accounts, keys, and audit records for product consoles.
npm install @authris/sdk

The code samples below are framework-agnostic pseudo-code. req.session.* / res.redirect(...) follow Express conventions — substitute your own framework's session store and redirect helper. The SDK is stateless: it never persists user sessions or tokens for you (except the in-memory caches noted below).


Before you start: getting your values

Every snippet needs an issuer, a client_id, usually a client_secret, and your redirect URI. Here is exactly where each comes from.

Issuer URL — the #1 thing to get right

Authris issues tokens per team, so the issuer is keyed by your team slug (not the project slug):

https://<your-authris-gateway>/o/<team-slug>
  • The path segment is /o/ — not /t/, not /api/.... Discovery lives at …/o/<team-slug>/.well-known/openid-configuration.
  • <team-slug> is your team's slug. Clients live inside projects inside a team, but the OIDC issuer is the team's — all clients in a team share one issuer.
  • Don't hand-assemble it. Open the client in the Authris Console → your client → Integration tab; it shows the exact Issuer, Discovery, and JWKS URLs with copy buttons. Copy the Issuer verbatim — that value is canonical for your deployment.

Quick check before wiring any code — this must return JSON, not a 404:

curl https://<your-authris-gateway>/o/<team-slug>/.well-known/openid-configuration

Where to create the client + register the redirect URI

In the Authris Console:

  1. Open (or create) a project under your team, then go to its Credentials page.
  2. New client → pick the app type:
    • Web → confidential client (gets a client_secret, client_secret_basic auth).
    • SPA / Android / iOS / Desktop → public client (PKCE only, no secret).
  3. Register your redirect URI(s) on that client (e.g. https://your-app.example.com/oauth/callback). Authris rejects any redirect_uri at /authorize that isn't registered here — an unregistered URI is the most common cause of an authorize failure.
  4. For a confidential client, open the Secrets tab and generate a secret (shown once — store it).
  5. To enable Device Flow, tick device_code under the client's Grant types.

Already using a framework auth library?

Authris is a standard OIDC provider, so you usually don't need this SDK for sign-in at all — point your library at the discovery URL:

  • better-auth (genericOAuth plugin) / Auth.js / NextAuth (custom OIDC provider): configure discoveryUrl = https://<gateway>/o/<team-slug>/.well-known/openid-configuration, your clientId, clientSecret, scopes openid profile email (add offline_access for refresh tokens), and pkce: true.

Reach for @authris/sdk directly when you need Device Flow, Service Accounts, PAT verification, or fine-grained control over the RP flow.


OIDC end-user sign-in

OidcClient holds the issuer + client identity and caches discovery + JWKS, so a constructed instance is cheap to reuse across requests.

import { OidcClient } from '@authris/sdk';

const oidc = new OidcClient({
  issuer: 'https://gateway.example.com/o/acme', // ← /o/<team-slug>; copy from Console → Integration
  clientId: 'YOUR_OAUTH_CLIENT_ID',
  clientSecret: process.env.AUTHRIS_CLIENT_SECRET, // omit for public clients
});

1. Build the authorize URL

buildAuthorizeUrl returns the URL plus the PKCE verifier, state, and nonce — the caller MUST persist those three in their session before redirecting, then read them back in the callback.

const { url, codeVerifier, state, nonce } = await oidc.buildAuthorizeUrl({
  redirectUri: 'https://your-app.example.com/oauth/callback', // must be registered on the client
  scope: ['openid', 'profile', 'email', 'offline_access'],    // add 'offline_access' to get a refresh_token
});

// Stash the trio in your session, then redirect.
req.session.codeVerifier = codeVerifier;
req.session.state = state;
req.session.nonce = nonce;
res.redirect(url);

Refresh tokens require offline_access. Without that scope the token endpoint returns no refresh_token, and oidc.refresh(...) (below) has nothing to work with.

Client-hosted social buttons (idp hint)

Put your own "Sign in with Google / GitHub / …" buttons on your page and have each jump straight to that provider via Authris — skipping the Authris method picker. Pass idp:

// Your "Sign in with Google" button
const { url, codeVerifier, state, nonce } = await oidc.buildAuthorizeUrl({
  redirectUri: 'https://your-app.example.com/oauth/callback',
  scope: ['openid', 'profile', 'email'],
  idp: 'google',          // 'google' | 'github' | 'oidc:<key>'
});
res.redirect(url);

The user takes one trip through the provider and lands back on your redirect_uri?code=... — exchange it exactly as below. If the team hasn't enabled the named provider, Authris falls back to its normal method picker (no error). Social login always involves one redirect to the provider, so there is no headless equivalent.

2. Exchange the code

In your callback handler, verify the state matches the stashed value (defends against CSRF) and exchange the code:

const tokens = await oidc.exchangeCode({
  code: req.query.code,
  redirectUri: 'https://your-app.example.com/oauth/callback',
  codeVerifier: req.session.codeVerifier,
});
// → { access_token, id_token, refresh_token, expires_in, ... }

exchangeCode does NOT validate the returned id_token — that step is separate so you can pass back the stashed nonce:

const claims = await oidc.verifyIdToken(tokens.id_token, { nonce: req.session.nonce });
console.log(claims.sub, claims.email);

3. Refresh

Authris rotates refresh tokens on every use, so persist the returned refresh_token over the old one (the previous one is immediately invalidated — reuse is treated as theft).

const refreshed = await oidc.refresh(req.session.refreshToken);
req.session.refreshToken = refreshed.refresh_token;

When you need retry-safe refresh, use OidcUserTokenManager below. It sends an idempotency_key on refresh and retries network transport failures with the same key.

4. Logout (RP-Initiated)

const logoutUrl = await oidc.buildEndSessionUrl({
  idTokenHint: req.session.idToken,
  postLogoutRedirectUri: 'https://your-app.example.com/bye',
});
res.redirect(logoutUrl);

Alternative: headless password auth (client-hosted pages)

If you'd rather host your own sign-up / sign-in / forgot-password pages instead of redirecting to Authris, submit the credentials directly. These return the same OIDC tokens as the redirect flow (same sub, signing key, refresh rotation), so everything downstream — verifyIdToken, getUserInfo, refresh — is unchanged. Public SPA clients need only clientId (no secret).

// Register → tokens (register-then-login). Throws OidcClientError('email_exists') on a dupe.
const tokens = await oidc.signUp({ name: 'Ann', email: '[email protected]', password });

// Log in → tokens. Bad credentials throw OidcClientError('invalid_grant').
const tokens2 = await oidc.signInWithPassword({ email: '[email protected]', password });

// Forgot password → emails a reset link. Always resolves (no email enumeration).
// resetRedirectUri must exactly match one of the client's registered redirect_uris,
// otherwise the gateway falls back to the portal reset page.
await oidc.forgotPassword({ email: '[email protected]', resetRedirectUri: 'https://your-app.example.com/reset' });

// Reset password → uses the single-use token from the email link.
await oidc.resetPassword({ token: tokenFromEmail, newPassword });

Headless password auth means your app handles the raw password — it bypasses SSO / social / passkey / MFA. Prefer the redirect flow above unless you specifically need an embedded login UI. Always serve these pages over HTTPS.

Verify access tokens / fetch userinfo / introspect / revoke

await oidc.verifyAccessToken(accessToken);  // local JWT verification (JWKS)
await oidc.verifyTokenAuto(bearer);         // JWT OR `authris_pat_*` — dispatches automatically
await oidc.getUserInfo(accessToken);
await oidc.introspect(refreshToken, 'refresh_token');
await oidc.revoke(refreshToken, 'refresh_token');

Resource-server audience gotcha. Authris signs end-user access tokens with aud = "<issuer>/userinfo", and verifyAccessToken defaults to checking exactly that. Service-account resource tokens use the requested resource value as aud. Pass your API audience explicitly: verifyAccessToken(token, { audience: 'https://api.example.com' }). Mismatched audience throws OidcClientError with code = 'invalid_audience'.

verifyTokenAuto is the right entry point for resource servers that may receive either JWT access_tokens (interactive sessions) or PATs (CLI / scripts) on the same Authorization: Bearer header — see the CLI section below.

PAT verification needs a confidential client. PATs (authris_pat_*) have no signature, so verifyTokenAuto validates them via the introspection endpoint, which authenticates the caller with clientSecret. A public client (no secret) therefore cannot verify PATs — verifyTokenAuto throws OidcClientError with code = 'missing_secret'. Use a confidential client on any resource server that accepts PATs.

Low-level PKCE primitives

Useful for split architectures (mint PKCE on the edge, exchange in the backend):

import { generatePkcePair, generateState, generateNonce, pkceChallengeFor } from '@authris/sdk';

const { codeVerifier, codeChallenge, codeChallengeMethod } = generatePkcePair();
const challenge = pkceChallengeFor(existingVerifier);

Building a CLI tool (Device Flow + PAT)

Authris supports two long-lived credential models that make it cheap to build a gh-style CLI on top of your OAuth client:

  • Device Flow (RFC 8628) — interactive login from a terminal. The CLI displays a short user code; the user visits a URL in their browser and approves; the CLI polls until it receives an access_token + refresh_token. No client secret, no callback server.
  • Personal Access Tokens (PAT) — long-lived opaque tokens (prefix authris_pat_) the user creates from the portal /settings page. Same wire shape as access_tokens; users paste one into your CLI's login --token command for non-interactive automation.

Make sure your OAuth client has device_code checked in the Grant types section of its Console configuration page before relying on either.

Device Flow (CLI side)

import { runDeviceFlow } from '@authris/sdk';

const tokens = await runDeviceFlow(
  {
    issuer: 'https://gateway.example.com/o/acme',
    clientId: 'YOUR_PUBLIC_CLIENT_ID',
    scope: 'openid profile email',
  },
  {
    onPrompt: ({ verificationUriComplete, userCode }) => {
      console.log(`\n  Visit: ${verificationUriComplete}`);
      console.log(`  Code:  ${userCode}\n`);
    },
  },
);
// → { accessToken, refreshToken, idToken, expiresIn, ... }

The lower-level startDeviceFlow + pollDeviceToken are exported too if you need to drive the polling loop yourself.

Auto-refreshing the token (OidcUserTokenManager)

A user token set is a (access_token, refresh_token, expiresAt) triple. OidcUserTokenManager holds it for you, refreshes proactively a few seconds before expiry, deduplicates concurrent refreshes, retries refresh network failures with one idempotency key, and writes the rotated refresh_token back to a pluggable TokenStorage so it survives restarts.

import {
  OidcClient,
  OidcUserTokenManager,
  runDeviceFlow,
  type TokenStorage,
} from '@authris/sdk';

const oidc = new OidcClient({
  issuer: 'https://gateway.example.com/o/acme',
  clientId: 'YOUR_PUBLIC_CLIENT_ID',
});

// Storage backend is up to you — the SDK stays Node/edge/RN agnostic.
const fileStorage: TokenStorage = {
  load: async () => /* read JSON from ~/.config/your-app/auth.json */,
  save: async (t) => /* write JSON, mode 0o600 */,
  clear: async () => /* unlink the file */,
};

const tokens = new OidcUserTokenManager({ oidc, storage: fileStorage });

// First run: bootstrap via Device Flow, hand the result to the manager.
if (!(await tokens.getTokens())) {
  const fresh = await runDeviceFlow(
    { issuer: oidc.issuer, clientId: 'YOUR_PUBLIC_CLIENT_ID', scope: 'openid profile email offline_access' },
    {
      onPrompt: ({ verificationUriComplete, userCode }) => {
        console.log(`Visit: ${verificationUriComplete}\nCode:  ${userCode}`);
      },
    },
  );
  await tokens.setTokens(OidcUserTokenManager.fromTokenResponse(fresh));
}

// Every subsequent request just asks for a token — refresh is automatic.
await fetch('https://api.example.com/...', {
  headers: { Authorization: `Bearer ${await tokens.getAccessToken()}` },
});

Behaviour notes:

  • PAT short-circuit: tokens starting with authris_pat_ are returned untouched, never refreshed (they have no refresh_token).
  • Concurrency: parallel getAccessToken() calls during a refresh share one in-flight network request — no thundering herd.
  • Refresh retry: refresh transport failures retry 3 times by default with 5s, 10s, 15s waits. Set refreshRetryCount: 0 to disable. HTTP token errors such as invalid_grant are not retried.
  • Idempotency: every logical refresh gets one idempotency_key; all retries reuse it so a lost token response can be replayed by the gateway.
  • Storage write order: the rotated refresh_token is persisted before the in-memory state is updated, so a crash mid-refresh doesn't leave you with a token that's only on disk or only in memory.
  • No setInterval: refresh is lazy on demand — works in serverless / single-shot CLI runs without leaking timers.
  • On failure: getAccessToken() throws OidcClientError with code = 'invalid_grant' when refresh fails (RT revoked / expired). Catch it, call tokens.clear(), and re-run Device Flow.

Minimal TokenStorage (file-backed)

A 20-line atomic file storage is usually enough; reach for OS keychain only if you actually need it. 0600 mode + write-temp-then-rename keeps it safe from partial writes:

import { writeFileSync, readFileSync, mkdirSync, chmodSync, renameSync, unlinkSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { homedir } from 'node:os';

const PATH = join(homedir(), '.config', 'your-app', 'auth.json');

export const fileStorage: TokenStorage = {
  async load() {
    try { return JSON.parse(readFileSync(PATH, 'utf8')); } catch { return null; }
  },
  async save(tokens) {
    mkdirSync(dirname(PATH), { recursive: true });
    const tmp = `${PATH}.tmp`;
    writeFileSync(tmp, JSON.stringify(tokens), { mode: 0o600 });
    chmodSync(tmp, 0o600);
    renameSync(tmp, PATH);  // atomic on POSIX
  },
  async clear() {
    try { unlinkSync(PATH); } catch { /* ok if missing */ }
  },
};

For OS-keychain storage (what gh does), wrap a library like keytar or @napi-rs/keyring against the same TokenStorage shape — the manager doesn't care.

Verifying PATs (server side)

A PAT arrives at your backend in the same header shape as a JWT access_token (Authorization: Bearer authris_pat_*). verifyTokenAuto accepts either kind and returns claims in JWTPayload shape so resource-server code stays uniform:

import { OidcClient, OidcClientError, isPatToken } from '@authris/sdk';

const oidc = new OidcClient({
  issuer: 'https://gateway.example.com/o/acme',
  clientId: 'YOUR_OAUTH_CLIENT_ID',
  clientSecret: process.env.AUTHRIS_CLIENT_SECRET, // required — PAT introspect authenticates the caller
});

try {
  const claims = await oidc.verifyTokenAuto(bearer);
  // claims.sub, claims.scope, claims.client_id are populated for both PATs and JWTs.
} catch (err) {
  if (err instanceof OidcClientError && err.code === 'invalid_token') {
    // unknown / revoked / expired / scoped to a different client
  }
}

Use isPatToken(bearer) (true when the token starts with the authris_pat_ prefix, also exported as PAT_TOKEN_PREFIX) if you need to branch before verification — e.g. tag the audit log differently for PAT vs interactive sessions, route your own opaque tokens elsewhere, or short-circuit when your endpoint only allows one kind.


Service Accounts (M2M)

Service accounts are non-user clients that authenticate to the token endpoint with a private_key_jwt client assertion (RFC 7521/7523). The client holds the PKCS#8 private key in-process; Authris holds only the matching public key. Tokens are short-lived JWT access_tokens (sub = sa:<client_id>), suitable for backend-to-backend calls.

Mint + cache access tokens

import { ServiceAccountClient } from '@authris/sdk';

const saPrivateKeyPem = process.env.AUTHRIS_SA_PRIVATE_KEY;
if (!saPrivateKeyPem) throw new Error('AUTHRIS_SA_PRIVATE_KEY is required');

const sa = new ServiceAccountClient({
  issuer: 'https://gateway.example.com/o/acme',
  clientId: 'YOUR_SERVICE_ACCOUNT_CLIENT_ID',
  privateKeyPem: saPrivateKeyPem,                     // PKCS#8 PEM
  keyId: process.env.AUTHRIS_SA_KEY_ID,               // kid header (optional but recommended)
});

// On every outbound request to a resource server:
const token = await sa.getAccessTokenFor({
  resource: 'https://api.example.com',
  scopes: ['files:read', 'files:write'],
});
await fetch('https://api.example.com/...', {
  headers: { Authorization: `Bearer ${token}` },
});

The first call mints (signs a fresh client_assertion JWT and POSTs grant_type=client_credentials to the token endpoint); subsequent calls hit an in-memory cache keyed by resource + scopes and refresh proactively when within 30 s of expiry. Concurrent callers during a refresh share one in-flight request — no thundering herd.

getAccessToken() without arguments is still available for legacy service-account tokens whose audience is the service account client_id. Resource servers should prefer getAccessTokenFor({ resource, scopes }).

Provisioning from a product console

Use M2mManagementClient when your product, such as storemux, needs to create credentials for its own customers or downstream projects. The caller itself is a management service account and must hold the corresponding Authris management scopes:

  • authris:resources:write to create resource audiences and scope catalogs.
  • authris:projects:write to create or disable projects.
  • authris:clients:write to create or disable service accounts.
  • authris:credentials:write to create, rotate, expire, or disable keys.
  • authris:audit:read to inspect management audit events.
import { M2mManagementClient, ServiceAccountClient } from '@authris/sdk';

const managementClientId = process.env.AUTHRIS_MGMT_CLIENT_ID;
const managementKeyId = process.env.AUTHRIS_MGMT_KEY_ID;
const managementPrivateKeyPem = process.env.AUTHRIS_MGMT_PRIVATE_KEY;
if (!managementClientId) throw new Error('AUTHRIS_MGMT_CLIENT_ID is required');
if (!managementKeyId) throw new Error('AUTHRIS_MGMT_KEY_ID is required');
if (!managementPrivateKeyPem) throw new Error('AUTHRIS_MGMT_PRIVATE_KEY is required');

const managementSa = new ServiceAccountClient({
  issuer: 'https://gateway.example.com/o/storemux',
  clientId: managementClientId,
  keyId: managementKeyId,
  privateKeyPem: managementPrivateKeyPem,
});

const management = new M2mManagementClient({
  baseUrl: 'https://gateway.example.com/api/t/storemux',
  fetchImpl: null, // use global fetch
  getAccessToken: () => managementSa.getAccessTokenFor({
    resource: 'https://gateway.example.com/api/t/storemux',
    scopes: [
      'authris:resources:write',
      'authris:projects:write',
      'authris:clients:write',
      'authris:credentials:write',
      'authris:audit:read',
    ],
  }),
});

const project = await management.createProject({
  name: 'Project A',
  slug: 'project-a',
  description: 'Project A in storemux',
  logoUri: null,
  homepageUri: null,
});

const filesResource = await management.createResource({
  name: 'Storemux Files',
  audience: 'https://api.storemux.example/files',
  description: 'Storemux file management API',
  scopes: ['files:read', 'files:write'],
});

const projectA = await management.createServiceAccount({
  projectId: project.id,
  name: 'Project A backend',
  description: 'Project A calls storemux files API',
  allowedScopes: [],
  resourceGrants: [{ resourceId: filesResource.id, scopes: ['files:read', 'files:write'] }],
});

const key = await management.createServiceAccountKey({
  clientId: projectA.id,
  name: 'project-a-prod',
  expiresAt: '2026-12-31T00:00:00.000Z',
});

// Persist key.privateKeyPem in Project A's secret store now. It is returned once.

Project A then exchanges that key for a short-lived access token scoped to the storemux files resource:

const projectASa = new ServiceAccountClient({
  issuer: 'https://gateway.example.com/o/storemux',
  clientId: projectA.id,
  keyId: key.kid,
  privateKeyPem: key.privateKeyPem,
});

const accessToken = await projectASa.getAccessTokenFor({
  resource: 'https://api.storemux.example/files',
  scopes: ['files:read'],
});

Lists and later reads never return historical private keys:

await management.listServiceAccountKeys({ clientId: projectA.id });
await management.listAuditEvents({ limit: 50 });

Provisioning the keypair

The Console generates the keypair when you create a service account and shows the private key once. Save it as a single-line PKCS#8 PEM (with the BEGIN PRIVATE KEY / END PRIVATE KEY headers) in your secret store; Authris persists only the public half and rejects the key after that initial reveal. Rotate by adding a new key under the SA in Console and revoking the old one.

Server-side verification

A service-account access_token is a regular JWT signed by the issuer's JWKS. Resource tokens carry aud = resource, scope, team_id, client_id, and sub = sa:<client_id>, so you can split SA traffic from user traffic in the same handler:

const claims = await oidc.verifyAccessToken(bearer, { audience: 'https://api.example.com' });

if (typeof claims.sub === 'string' && claims.sub.startsWith('sa:')) {
  // service account — no user identity; authorize by client_id + scope
} else {
  // end-user access_token — authorize by the user's sub
}

CLIENT_ASSERTION_TYPE

Exported for callers that build the client_assertion themselves (e.g. signing in a different language and POSTing manually):

import { CLIENT_ASSERTION_TYPE } from '@authris/sdk';
// = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"

Error handling

Every failure throws OidcClientError (or DeviceFlowError / ServiceAccountClientError for those clients) with a stable .code you can branch on, plus .message and an optional .cause:

import { OidcClientError } from '@authris/sdk';

try {
  await oidc.verifyTokenAuto(bearer);
} catch (err) {
  if (err instanceof OidcClientError && err.code === 'invalid_token') reject();
}

OidcClientError.code values:

| code | meaning | |------|---------| | invalid_issuer | discovery document's issuer didn't match the configured issuer | | invalid_audience | token aud didn't match the expected audience (see the audience gotcha above) | | expired_token | token is past its exp | | invalid_signature | JWT signature failed JWKS verification | | invalid_token | token unknown / revoked / malformed / scoped to a different client (also: inactive PAT) | | invalid_grant | code or refresh_token rejected at the token endpoint (e.g. reused / expired RT) | | missing_secret | a clientSecret-requiring call (introspect, PAT verify, revoke) was made on a client with no secret | | discovery_failed | /.well-known/openid-configuration couldn't be fetched/parsed (wrong issuer URL → check /o/<team-slug>) | | jwks_failed | the JWKS endpoint couldn't be fetched/parsed | | http_error | a token/userinfo/introspect/revoke HTTP call returned a non-2xx the SDK didn't map more specifically |

DeviceFlowError.code adds the RFC 8628 set (authorization_pending is absorbed internally; you'll see access_denied, expired_token, slow_down-handled, aborted, etc.).


Versioning

This documents @authris/sdk v3.x. The package follows semver — pin a major (^3) and review the changelog before bumping across majors, as the client surface (e.g. OidcClient options) can change between major versions.