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

@sedibyte/auth

v0.9.0

Published

Provider-agnostic authentication manager for various platforms (Microsoft, Salesforce, Discord, Google, Apple, Facebook, Okta, GitHub, Jira, with more to come).

Readme

@sedibyte/auth

A small, provider-agnostic access-token manager. It owns two cross-cutting concerns — a registry of auth providers and in-memory caching of tokens until shortly before they expire — so each provider only has to know how to request a fresh token.

Eight providers ship today — Microsoft (app-only / client credentials), Salesforce (OAuth 2.0 JWT bearer), Discord (bot token, OAuth 2.0 client credentials, and refresh token, plus interactive "Sign in with Discord" login), Google (service-account JWT bearer, plus interactive "Sign in with Google" login), Apple (interactive "Sign in with Apple" login plus ID-token validation), Facebook (interactive "Login with Facebook" plus ID-token validation), Okta (interactive "Sign in with Okta" login plus ID-token validation), and GitHub (interactive "Sign in with GitHub" login plus access-token validation) — but the core knows nothing about any of them; adding another provider doesn't touch auth.js.

Written in TypeScript; ships compiled ESM plus type declarations.

Requirements

  • Node.js >= 18 (uses the built-in fetch)
  • ESM ("type": "module")

Build

npm install
npm run build   # tsc → dist/

The published package exposes the compiled output in dist/; consumers import from the package name and get bundled .d.ts types automatically.

Usage

import { registerProvider } from "@sedibyte/auth";
import { createMicrosoftProvider, createGraphClient } from "@sedibyte/auth/microsoft";

registerProvider(
  createMicrosoftProvider({
    tenantId: "...",
    clientId: "...",
    clientSecret: "...",
  })
);

const graph = createGraphClient(); // or createGraphClient({ graphBase: ".../beta" })
const users = await graph.graphJson("users");

All configuration is passed in explicitly — nothing is read from the environment. The caller owns loading credentials (from env, a secrets vault, wherever) and passing them in.

Just a token

Skip the Graph client and get a raw bearer token for any registered provider:

import { getAccessToken } from "@sedibyte/auth";

const token = await getAccessToken("microsoft");

Multiple Microsoft tenants

name is configurable, so you can register several tenants side by side and point a Graph client at whichever you need:

registerProvider(createMicrosoftProvider({ name: "tenant-a", tenantId: "...", clientId: "...", clientSecret: "..." }));
registerProvider(createMicrosoftProvider({ name: "tenant-b", tenantId: "...", clientId: "...", clientSecret: "..." }));

const graphA = createGraphClient({ providerName: "tenant-a" });
const graphB = createGraphClient({ providerName: "tenant-b" });

Validating inbound tokens

The provider above is the outbound direction — acquiring a token so you can call Graph. To go the other way and verify an Entra bearer token that arrived at your own API, use the validator:

import { createMicrosoftValidator, TokenValidationError } from "@sedibyte/auth/microsoft";

const validator = createMicrosoftValidator({
  tenantId: "<tenant-guid>",
  audience: "<your-api-client-id>", // or App ID URI, or an array of both
});

// e.g. inside an Express middleware
try {
  const { claims } = await validator.validateToken(req.headers.authorization);
  req.user = claims; // iss/aud/exp/nbf and RS256 signature already verified
} catch (err) {
  if (err instanceof TokenValidationError) return res.sendStatus(401);
  throw err;
}

Signature verification and JWKS fetching/caching (including key rotation) are handled by jose. By default both v2.0 (.../v2.0) and v1.0 (sts.windows.net/{tid}/) issuers are accepted; pass issuer to pin one. validateToken accepts a raw JWT or a full Authorization header value (a leading Bearer is stripped).

Interactive login ("Sign in with Microsoft")

The provider above is app-only (no user present). To actually sign a user in with the OAuth 2.0 Authorization Code flow against Entra ID (redirect to Microsoft, handle the callback, get their identity), use createMicrosoftLogin. It uses PKCE by default and verifies the returned OpenID Connect ID token for you:

import { createMicrosoftLogin } from "@sedibyte/auth/microsoft";

const login = createMicrosoftLogin({
  tenantId: "<tenant-guid>", // or "common" / "organizations" / "consumers"
  clientId: "<client-id>",
  clientSecret: "<client-secret>", // omit for a public client that uses PKCE alone
  redirectUri: "https://app.example.com/auth/microsoft/callback",
  // scope defaults to "openid profile email"
});

// GET /auth/microsoft/login — send the user to Microsoft.
app.get("/auth/microsoft/login", (req, res) => {
  const { url, state, nonce, codeVerifier } = login.createAuthUrl();
  req.session.microsoft = { state, nonce, codeVerifier }; // persist for the callback
  res.redirect(url);
});

// GET /auth/microsoft/callback — Entra redirects back with ?code=...&state=...
app.get("/auth/microsoft/callback", async (req, res) => {
  const { state, nonce, codeVerifier } = req.session.microsoft ?? {};
  if (!state || req.query.state !== state) return res.sendStatus(400); // CSRF check
  const { claims } = await login.exchangeCode({
    code: String(req.query.code),
    codeVerifier,
    nonce,
  });
  req.session.user = { id: claims.oid, email: claims.email, name: claims.name };
  res.redirect("/");
});

Add the offline_access scope to also receive a refresh token; redeem it later with login.refreshTokens(refreshToken). The ID token is verified with the same machinery as createMicrosoftValidator. With a multi-tenant tenantId (common/organizations/consumers) the issuer isn't a single fixed string — pass issuer so the ID token validates correctly.

Salesforce (JWT bearer)

Server-to-server auth with no interactive login and no client secret. You sign a short-lived assertion with the private key whose certificate is uploaded to a connected app; Salesforce exchanges it for an access token.

import { registerProvider } from "@sedibyte/auth";
import { createSalesforceProvider, createSalesforceClient } from "@sedibyte/auth/salesforce";
import { readFileSync } from "node:fs";

registerProvider(
  createSalesforceProvider({
    clientId: "<connected-app-consumer-key>", // JWT `iss`
    username: "[email protected]",       // JWT `sub` (pre-authorized user)
    privateKey: readFileSync("server.key", "utf8"), // PKCS#8 PEM
    // loginUrl: "https://test.salesforce.com",     // sandbox; defaults to prod
  })
);

const sf = createSalesforceClient(); // uses the org's instance_url automatically
const { records } = await sf.query("SELECT Id, Name FROM Account LIMIT 5");
const account = await sf.restJson("sobjects/Account/001...");

The private key must be PKCS#8 (a -----BEGIN PRIVATE KEY----- block). Convert a legacy PKCS#1 key with openssl pkcs8 -topk8 -nocrypt -in old.key -out server.key.

The token endpoint doesn't return expires_in for this flow, so the provider caches for tokenLifetimeSec (default 3600); set it at or below your org's session timeout. As with Microsoft, name is configurable, so you can register several orgs side by side and point a client at each with providerName.

Interactive login ("Sign in with Salesforce") — the JWT-bearer flow above has no user present. To sign a user in with the OAuth 2.0 Authorization Code (web-server) flow, use createSalesforceLogin. It uses PKCE by default and verifies the returned OpenID Connect ID token:

import { createSalesforceLogin } from "@sedibyte/auth/salesforce";

const login = createSalesforceLogin({
  clientId: "<connected-app-consumer-key>",
  clientSecret: "<connected-app-consumer-secret>",
  redirectUri: "https://app.example.com/auth/salesforce/callback",
  // loginUrl: "https://test.salesforce.com", // sandbox; defaults to prod
  // scope defaults to "openid profile email"
});

// GET /auth/salesforce/login — send the user to Salesforce.
app.get("/auth/salesforce/login", (req, res) => {
  const { url, state, nonce, codeVerifier } = login.createAuthUrl();
  req.session.salesforce = { state, nonce, codeVerifier };
  res.redirect(url);
});

// GET /auth/salesforce/callback — Salesforce redirects back with ?code=...&state=...
app.get("/auth/salesforce/callback", async (req, res) => {
  const { state, nonce, codeVerifier } = req.session.salesforce ?? {};
  if (!state || req.query.state !== state) return res.sendStatus(400); // CSRF check
  const { claims, instanceUrl } = await login.exchangeCode({
    code: String(req.query.code),
    codeVerifier,
    nonce,
  });
  // claims.user_id / claims.email / claims.name; instanceUrl for REST calls
  req.session.user = { id: claims.user_id, email: claims.email, name: claims.name };
  res.redirect("/");
});

Add the refresh_token scope to also receive a refresh token; redeem it later with login.refreshTokens(refreshToken). login.getUserInfo(accessToken) reads the /userinfo profile.

Validating inbound tokens — to verify a Salesforce ID token that arrived at your own API, use the validator:

import { createSalesforceValidator, TokenValidationError } from "@sedibyte/auth/salesforce";

const validator = createSalesforceValidator({
  audience: "<connected-app-consumer-key>",
  // issuer defaults to https://login.salesforce.com; pass your sandbox or
  // My Domain URL otherwise, e.g. "https://mydomain.my.salesforce.com"
});

const { claims } = await validator.validateToken(req.headers.authorization);

Signature verification and JWKS fetching/caching (key rotation included) are handled by jose, against the org's {issuer}/id/keys endpoint.

Discord

Discord offers several ways to authenticate; this package covers the ones usable from a headless, server-to-server context. Pick a provider factory to match how your app authenticates, then use one REST client for all of them — it reads the right Authorization scheme (Bot vs Bearer) off the registered provider.

Bot token — the most common case. A static token from the developer portal, sent as Authorization: Bot <token>:

import { registerProvider } from "@sedibyte/auth";
import { createDiscordBotProvider, createDiscordClient } from "@sedibyte/auth/discord";

registerProvider(createDiscordBotProvider({ token: process.env.DISCORD_BOT_TOKEN }));

const discord = createDiscordClient();
const me = await discord.discordJson("users/@me");
const guilds = await discord.discordJson("users/@me/guilds");

OAuth 2.0 client credentials — the application's own bearer token (the app owner's), for calling Discord with no interactive login:

import { createDiscordProvider } from "@sedibyte/auth/discord";

registerProvider(
  createDiscordProvider({
    clientId: "<application-id>",
    clientSecret: "<client-secret>",
    scope: ["identify", "connections"], // defaults to "identify"
  })
);

OAuth 2.0 refresh token — refresh an access token obtained from an earlier authorization-code flow, headlessly (to drive that interactive flow, see "Sign in with Discord" below). Discord rotates the refresh token on every exchange, so read the new one back and persist it:

const discordAuth = createDiscordProvider({
  grantType: "refresh_token",
  clientId: "<application-id>",
  clientSecret: "<client-secret>",
  refreshToken: loadStoredRefreshToken(),
});
registerProvider(discordAuth);

await getAccessToken("discord");
saveRefreshToken(discordAuth.getRefreshToken()); // rotated — persist it

Discord tokens are opaque (not JWTs), so there's no inbound signature-validation analog to the Microsoft validator; to check a token you'd call an endpoint such as /oauth2/@me. As with the other providers, name is configurable for registering several apps side by side. The API base defaults to https://discord.com/api/v10; override apiBase to pin another version.

Interactive "Sign in with Discord" — to actually sign a user in with their Discord account (the OAuth 2.0 Authorization Code flow: redirect, callback, read their identity), use createDiscordLogin. Discord is not an OpenID Connect provider, so — like GitHub — the exchange returns an opaque access token, not an ID token, and identity comes from the REST API (GET /users/@me). There's no nonce; PKCE (S256) is supported and on by default, so state and the codeVerifier protect the round-trip. Discord rotates the refresh token on every exchange, so persist the one you get back.

import { createDiscordLogin } from "@sedibyte/auth/discord";

const login = createDiscordLogin({
  clientId: "<application-id>",
  clientSecret: "<client-secret>",
  redirectUri: "https://app.example.com/auth/discord/callback",
  // scope defaults to "identify email"
});

// GET /auth/discord/login — send the user to Discord.
app.get("/auth/discord/login", (req, res) => {
  const { url, state, codeVerifier } = login.createAuthUrl();
  req.session.discord = { state, codeVerifier }; // persist for the callback
  res.redirect(url);
});

// GET /auth/discord/callback — Discord redirects back with ?code=...&state=...
app.get("/auth/discord/callback", async (req, res) => {
  const { state, codeVerifier } = req.session.discord ?? {};
  if (!state || req.query.state !== state) return res.sendStatus(400); // CSRF check
  const { accessToken, refreshToken } = await login.exchangeCode({
    code: String(req.query.code),
    codeVerifier,
  });
  const me = await login.getProfile(accessToken); // { id, username, email, ... }
  saveRefreshToken(refreshToken); // rotated — persist it
  req.session.user = { id: me.id, username: me.username, email: me.email };
  res.redirect("/");
});

The redirectUri must exactly match one of the redirect URIs configured on the Discord application (OAuth2 → Redirects). Redeem a stored refresh token later with login.refreshTokens(refreshToken) — Discord rotates it, so persist the returned one.

Google (service-account JWT bearer)

Server-to-server auth with no interactive login and no client secret. You sign a short-lived assertion with a service account's private key; Google exchanges it for an access token. Pull client_email and private_key straight from the service-account JSON key:

import { registerProvider } from "@sedibyte/auth";
import { createGoogleProvider, createGoogleClient } from "@sedibyte/auth/google";
import { readFileSync } from "node:fs";

const key = JSON.parse(readFileSync("service-account.json", "utf8"));

registerProvider(
  createGoogleProvider({
    clientEmail: key.client_email,               // JWT `iss`
    privateKey: key.private_key,                 // PKCS#8 PEM
    scope: "https://www.googleapis.com/auth/userinfo.email", // string or array
    // subject: "[email protected]",       // domain-wide delegation
  })
);

const google = createGoogleClient(); // base defaults to https://www.googleapis.com
const me = await google.googleJson("oauth2/v3/userinfo");

scope is required — Google issues a token only for the scopes you ask for. Because Google spreads its APIs across many hosts, point the client at a service host with apiBase (e.g. https://gmail.googleapis.com) or pass an absolute URL to googleFetch/googleJson. To impersonate a user via domain-wide delegation, set subject; the service account must be authorized for those scopes in the Workspace admin console. As with the other providers, name is configurable for registering several service accounts side by side.

Validating inbound tokens — to verify a Google ID token that arrived at your own API (a "Sign in with Google" credential, an IAP assertion), use the validator, which mirrors the Microsoft one:

import { createGoogleValidator, TokenValidationError } from "@sedibyte/auth/google";

const validator = createGoogleValidator({
  audience: "<your-oauth-client-id>.apps.googleusercontent.com",
  // hostedDomain: "your-workspace.com", // restrict to a Workspace domain
});

try {
  const { claims } = await validator.validateToken(req.headers.authorization);
  req.user = claims; // iss/aud/exp and RS256 signature already verified
} catch (err) {
  if (err instanceof TokenValidationError) return res.sendStatus(401);
  throw err;
}

Interactive login ("Sign in with Google") — to actually sign a user in with the OAuth 2.0 Authorization Code flow (redirect to Google, handle the callback, get their identity), use createGoogleLogin. It uses PKCE by default and verifies the returned ID token for you:

import { createGoogleLogin } from "@sedibyte/auth/google";

const login = createGoogleLogin({
  clientId: "<your-oauth-client-id>.apps.googleusercontent.com",
  clientSecret: "<your-oauth-client-secret>",
  redirectUri: "https://app.example.com/auth/google/callback",
  // scope defaults to "openid email profile"
  // hostedDomain: "your-workspace.com", // restrict to a Workspace domain
});

// GET /auth/google/login — send the user to Google.
app.get("/auth/google/login", (req, res) => {
  const { url, state, nonce, codeVerifier } = login.createAuthUrl();
  req.session.google = { state, nonce, codeVerifier }; // persist for the callback
  res.redirect(url);
});

// GET /auth/google/callback — Google redirects back with ?code=...&state=...
app.get("/auth/google/callback", async (req, res) => {
  const { state, nonce, codeVerifier } = req.session.google ?? {};
  if (!state || req.query.state !== state) return res.sendStatus(400); // CSRF check
  const { claims } = await login.exchangeCode({
    code: String(req.query.code),
    codeVerifier,
    nonce,
  });
  req.session.user = { id: claims.sub, email: claims.email, name: claims.name };
  res.redirect("/");
});

Pass accessType: "offline" (usually with prompt: "consent") to createAuthUrl to also receive a refresh token; redeem it later with login.refreshTokens(refreshToken).

Apple ("Sign in with Apple")

Apple is an identity provider only — there's no server-to-server API token to acquire — so this provider ships just the two halves of an SSO integration: interactive login and inbound ID-token validation.

Two things make Apple different from a textbook OIDC provider, and createAppleLogin hides both. First, the client secret is an ES256 JWT you sign yourself from your Team ID, Key ID, and .p8 private key — the library mints a fresh, short-lived one for every token request, so you never store a secret that expires. Second, the user's name is returned only once: when you request the name/email scope, Apple uses response_mode=form_post (the callback is an HTTP POST) and includes the name in a JSON user field only on the first authorization — never in the ID token. Capture it then with parseUserField, or not at all.

import { createAppleLogin, parseUserField } from "@sedibyte/auth/apple";
import { readFileSync } from "node:fs";

const login = createAppleLogin({
  clientId: "com.example.app.service",              // your Services ID
  teamId: "ABCDE12345",                              // 10-char Team ID
  keyId: "K1234ABCDE",                               // 10-char Key ID
  privateKey: readFileSync("AuthKey_K1234ABCDE.p8", "utf8"), // the .p8 (PKCS#8 PEM)
  redirectUri: "https://app.example.com/auth/apple/callback", // must be HTTPS
  // scope defaults to "name email"
});

// GET /auth/apple/login — send the user to Apple.
app.get("/auth/apple/login", (req, res) => {
  const { url, state, nonce, codeVerifier } = login.createAuthUrl();
  req.session.apple = { state, nonce, codeVerifier }; // persist for the callback
  res.redirect(url);
});

// POST /auth/apple/callback — Apple form-POSTs back code, state, and (first time) user.
app.post("/auth/apple/callback", async (req, res) => {
  const { state, nonce, codeVerifier } = req.session.apple ?? {};
  if (!state || req.body.state !== state) return res.sendStatus(400); // CSRF check
  const { claims } = await login.exchangeCode({
    code: String(req.body.code),
    codeVerifier,
    nonce,
  });
  const profile = parseUserField(req.body.user); // { firstName, lastName, email } — only on first sign-in
  req.session.user = {
    id: claims.sub,
    email: claims.email,
    name: profile && [profile.firstName, profile.lastName].filter(Boolean).join(" "),
  };
  res.redirect("/");
});

The redirectUri must be HTTPS and exactly match a "Return URL" on the Services ID — Apple rejects plain-HTTP and bare-IP redirects, even for localhost. Apple issues a refresh token on the code exchange; it stays the same across refreshes and is how you check (up to once a day) that the account is still valid — login.refreshTokens(refreshToken).

Validating inbound tokens — to verify an Apple ID token that arrived at your own API (e.g. one relayed from a native iOS client), use the validator, which mirrors the Google and Microsoft ones:

import { createAppleValidator, TokenValidationError } from "@sedibyte/auth/apple";

const validator = createAppleValidator({
  audience: "com.example.app.service", // your Services ID (or the app's bundle ID)
});

try {
  const { claims } = await validator.validateToken(idToken);
  req.user = claims; // iss/aud/exp and RS256 signature already verified
} catch (err) {
  if (err instanceof TokenValidationError) return res.sendStatus(401);
  throw err;
}

Facebook ("Login with Facebook")

Facebook is an identity provider only — there's no server-to-server API token to acquire — so this provider ships the two halves of an SSO integration: interactive login and inbound ID-token validation.

Facebook differs from a textbook OIDC provider in a few ways createFacebookLogin hides. First, identity usually comes from the Graph API, not an ID token: the plain login returns an opaque access token, and you read the profile from GET /me — request the openid scope to also get a signed ID token (the library sends a nonce and verifies it for you). Second, there are no refresh tokens; you swap a short-lived token for a long-lived one (~60 days) with exchangeForLongLivedToken. Graph calls are signed with an appsecret_proof automatically.

import { createFacebookLogin } from "@sedibyte/auth/facebook";

const login = createFacebookLogin({
  clientId: "<app-id>",
  clientSecret: "<app-secret>",
  redirectUri: "https://app.example.com/auth/facebook/callback",
  // scope defaults to "public_profile email"; add "openid" for an ID token
});

// GET /auth/facebook/login — send the user to Facebook.
app.get("/auth/facebook/login", (req, res) => {
  const { url, state, nonce, codeVerifier } = login.createAuthUrl();
  req.session.facebook = { state, nonce, codeVerifier }; // persist for the callback
  res.redirect(url);
});

// GET /auth/facebook/callback — Facebook redirects back with ?code=...&state=...
app.get("/auth/facebook/callback", async (req, res) => {
  const { state, nonce, codeVerifier } = req.session.facebook ?? {};
  if (!state || req.query.state !== state) return res.sendStatus(400); // CSRF check
  const { accessToken } = await login.exchangeCode({
    code: String(req.query.code),
    codeVerifier,
    nonce,
  });
  const me = await login.getProfile(accessToken); // { id, name, email }
  req.session.user = { id: me.id, email: me.email, name: me.name };
  res.redirect("/");
});

The redirectUri must exactly match a "Valid OAuth Redirect URI" configured on the app. Facebook has no refresh tokens; call login.exchangeForLongLivedToken(accessToken) to trade a short-lived token for a long-lived one.

Validating inbound tokens — when you request the openid scope (or accept an ID token relayed from a native client using Limited Login), verify it with the validator, which mirrors the Google and Apple ones. Facebook's plain access tokens are opaque, not JWTs — check those via the Graph debug_token endpoint instead.

import { createFacebookValidator, TokenValidationError } from "@sedibyte/auth/facebook";

const validator = createFacebookValidator({
  audience: "<your-app-id>",
});

try {
  const { claims } = await validator.validateToken(idToken);
  req.user = claims; // iss/aud/exp and RS256 signature already verified
} catch (err) {
  if (err instanceof TokenValidationError) return res.sendStatus(401);
  throw err;
}

Okta ("Sign in with Okta")

Okta is a textbook OpenID Connect provider, so this login mirrors the Google one — the only Okta-specific twist is that there's no global issuer: every Okta org (and every authorization server within it) has its own. Pass that issuer and the authorize, token, and userinfo endpoints (and the validator's JWKS) are derived from it.

import { createOktaLogin } from "@sedibyte/auth/okta";

const login = createOktaLogin({
  issuer: "https://your-org.okta.com/oauth2/default", // or ".../oauth2/<custom-as-id>"
  clientId: "<client-id>",
  clientSecret: "<client-secret>",
  redirectUri: "https://app.example.com/auth/okta/callback",
  // scope defaults to "openid email profile"; add "offline_access" for a refresh token
});

// GET /auth/okta/login — send the user to Okta.
app.get("/auth/okta/login", (req, res) => {
  const { url, state, nonce, codeVerifier } = login.createAuthUrl();
  req.session.okta = { state, nonce, codeVerifier }; // persist for the callback
  res.redirect(url);
});

// GET /auth/okta/callback — Okta redirects back with ?code=...&state=...
app.get("/auth/okta/callback", async (req, res) => {
  const { state, nonce, codeVerifier } = req.session.okta ?? {};
  if (!state || req.query.state !== state) return res.sendStatus(400); // CSRF check
  const { claims } = await login.exchangeCode({
    code: String(req.query.code),
    codeVerifier,
    nonce,
  });
  req.session.user = { id: claims.sub, email: claims.email, name: claims.name };
  res.redirect("/");
});

The redirectUri must exactly match a "Sign-in redirect URI" configured on the Okta app. Add the offline_access scope to receive a refresh token and redeem it later with login.refreshTokens(refreshToken) (Okta may rotate it, so read the returned one back and persist it). Need more than the ID-token claims? Call login.getUserInfo(accessToken) to read the /v1/userinfo endpoint.

Validating inbound tokens — to verify an Okta ID token that arrived at your own API (e.g. one relayed from a native client), use the validator, which mirrors the Google, Apple, and Facebook ones. Because the issuer is org-specific, it's required alongside the audience:

import { createOktaValidator, TokenValidationError } from "@sedibyte/auth/okta";

const validator = createOktaValidator({
  issuer: "https://your-org.okta.com/oauth2/default",
  audience: "<your-client-id>",
});

try {
  const { claims } = await validator.validateToken(idToken);
  req.user = claims; // iss/aud/exp and RS256 signature already verified
} catch (err) {
  if (err instanceof TokenValidationError) return res.sendStatus(401);
  throw err;
}

GitHub

This provider covers both server-to-server API access and the two halves of an SSO integration (interactive login and inbound access-token validation).

Calling the GitHub API — GitHub API access is authenticated with a token you already hold: a classic or fine-grained personal access token, or an app installation token you minted elsewhere. There's no token to fetch, so createGitHubProvider just adapts your token into the provider interface; the REST client sends it as Authorization: Bearer <token> alongside the Accept, X-GitHub-Api-Version, and User-Agent headers GitHub requires.

import { registerProvider } from "@sedibyte/auth";
import { createGitHubProvider, createGitHubClient } from "@sedibyte/auth/github";

registerProvider(createGitHubProvider({ token: "<personal-access-token>" }));

const gh = createGitHubClient();
const me = await gh.githubJson("/user"); // { login, id, ... }
const issues = await gh.githubJson("/repos/owner/repo/issues?state=open");

// Create an issue:
await gh.githubJson("/repos/owner/repo/issues", {
  method: "POST",
  body: JSON.stringify({ title: "Bug report", body: "…" }),
});

For GitHub Enterprise Server, set apiBaseUrl to https://HOSTNAME/api/v3. As elsewhere, name/providerName are configurable, so you can register several tokens side by side and point a client at each.

Interactive login ("Sign in with GitHub") — unlike the OIDC providers above, GitHub is not an OpenID Connect provider, and createGitHubLogin reflects that honestly. The exchange returns an opaque access token, not an ID token, so identity comes from the REST API: read the profile from GET /user and, because that often omits a private address, the addresses from GET /user/emails. There's no nonce and no PKCE (GitHub's OAuth apps don't support it), so state is the round-trip's only integrity check. GitHub also signals token-exchange errors with an HTTP 200 body — the library detects that and throws for you.

import { createGitHubLogin } from "@sedibyte/auth/github";

const login = createGitHubLogin({
  clientId: "<client-id>",
  clientSecret: "<client-secret>",
  redirectUri: "https://app.example.com/auth/github/callback",
  // scope defaults to "read:user user:email"
});

// GET /auth/github/login — send the user to GitHub.
app.get("/auth/github/login", (req, res) => {
  const { url, state } = login.createAuthUrl();
  req.session.github = { state }; // persist for the callback
  res.redirect(url);
});

// GET /auth/github/callback — GitHub redirects back with ?code=...&state=...
app.get("/auth/github/callback", async (req, res) => {
  const { state } = req.session.github ?? {};
  if (!state || req.query.state !== state) return res.sendStatus(400); // CSRF check
  const { accessToken } = await login.exchangeCode({ code: String(req.query.code) });
  const me = await login.getProfile(accessToken); // { id, login, name, email }
  const [primary] = (await login.getEmails(accessToken)).filter((e) => e.primary);
  req.session.user = { id: me.id, login: me.login, email: primary?.email ?? me.email };
  res.redirect("/");
});

The redirectUri must match the "Authorization callback URL" configured on the GitHub OAuth app. Plain OAuth-app tokens don't expire; only if you enable "expiring user-to-server tokens" does the exchange return a refreshToken (redeem it with login.refreshTokens(refreshToken) — GitHub rotates it, so persist the returned one). For GitHub Enterprise Server, override authUri/tokenUri with your instance's https://HOSTNAME/login/oauth/... and set apiBaseUrl to https://HOSTNAME/api/v3.

Validating inbound tokens — to verify a GitHub access token that arrived at your own API, use the validator. Because GitHub tokens are opaque (not JWTs), there's nothing to verify locally; it introspects the token against GitHub's OAuth-app check endpoint, confirming it was issued for your app and returning the granting user and scopes. That's a network call per check, so cache the result.

import { createGitHubValidator, TokenValidationError } from "@sedibyte/auth/github";

const validator = createGitHubValidator({
  clientId: "<client-id>",
  clientSecret: "<client-secret>",
});

try {
  const { user, scopes } = await validator.validateToken(accessToken);
  req.user = user; // token confirmed issued for this app
} catch (err) {
  if (err instanceof TokenValidationError) return res.sendStatus(401);
  throw err;
}

Jira

Server-to-server access to the Jira REST API with a credential you already hold. Pick a provider factory to match your deployment, then use one REST client for either — it reads the right Authorization scheme (Basic vs Bearer) and the site base URL off the registered provider.

Jira Cloud — authenticate with your Atlassian account email and an API token, sent as HTTP Basic auth:

import { registerProvider } from "@sedibyte/auth";
import { createJiraProvider, createJiraClient } from "@sedibyte/auth/jira";

registerProvider(
  createJiraProvider({
    baseUrl: "https://your-domain.atlassian.net",
    email: "[email protected]",
    apiToken: "<api-token>",
  })
);

const jira = createJiraClient(); // uses the provider's base URL automatically
const issue = await jira.jiraJson("issue/PROJ-1");

// JQL search (POST /rest/api/3/search/jql):
const { issues } = await jira.search("project = PROJ ORDER BY created DESC", {
  fields: ["summary", "status"],
});

// Create an issue:
await jira.jiraJson("issue", {
  method: "POST",
  body: JSON.stringify({
    fields: { project: { key: "PROJ" }, summary: "New bug", issuetype: { name: "Bug" } },
  }),
});

Jira Data Center / Server — swap in createJiraTokenProvider with a personal access token (sent as Authorization: Bearer <token>). The client is identical; Data Center commonly uses the v2 REST API, so pass apiVersion: "2":

import { createJiraTokenProvider, createJiraClient } from "@sedibyte/auth/jira";

registerProvider(
  createJiraTokenProvider({
    baseUrl: "https://jira.example.com",
    token: "<personal-access-token>",
  })
);

const jira = createJiraClient({ apiVersion: "2" });

Relative paths resolve against {baseUrl}/rest/api/{apiVersion}/; a path starting with / (e.g. "/rest/agile/1.0/board") is treated as site-relative, and an absolute https:// URL is used as-is. As elsewhere, name/providerName are configurable, so you can register several sites side by side.

API

@sedibyte/auth

  • registerProvider(provider) — registers a provider { name, requestToken() }.
  • getAccessToken(providerName) — returns a cached token, or requests a fresh one.
  • clearTokenCache(providerName?) — clears one provider's cached token, or all.
  • getProvider(name) — looks up a registered provider (or undefined).

A provider's requestToken() returns { accessToken: string, expiresIn: number } (expiresIn in seconds). Tokens are refreshed 60s before real expiry.

@sedibyte/auth/microsoft

  • createMicrosoftProvider({ tenantId, clientId, clientSecret, name?, scope? }) — creates a Microsoft app-only provider. name defaults to "microsoft", scope to https://graph.microsoft.com/.default.
  • createGraphClient({ providerName?, graphBase? }) — returns { graphFetch, graphJson }. providerName defaults to "microsoft", graphBase to the Graph v1.0 endpoint.
    • graphFetch(path, init?) — authenticated fetch; path may be relative or an absolute URL.
    • graphJson(path, init?) — same, but parses JSON and throws on non-2xx.
  • createMicrosoftValidator({ tenantId, audience, authority?, issuer?, jwksUri?, clockToleranceSec? }) — returns { validateToken(token) } for verifying inbound Entra tokens. authority defaults to https://login.microsoftonline.com, issuer to the v2.0 and v1.0 issuers for the tenant, jwksUri to the tenant's v2.0 signing keys, and clockToleranceSec to 300.
    • validateToken(token) — verifies RS256 signature, issuer, audience, and exp/nbf. Resolves with { header, claims } or rejects with a TokenValidationError. Accepts a raw JWT or a Bearer … header value.
  • createMicrosoftLogin({ tenantId, clientId, clientSecret?, redirectUri, scope?, authority?, authUri?, tokenUri?, issuer?, clockToleranceSec? }) — creates an interactive "Sign in with Microsoft" (Authorization Code + PKCE) flow. scope defaults to openid profile email; clientSecret is optional for public/PKCE clients. Returns { createAuthUrl, exchangeCode, refreshTokens }.
    • createAuthUrl({ state?, nonce?, usePkce?, prompt?, loginHint?, domainHint?, scope? }) — returns { url, state, nonce, codeVerifier? }; persist the latter three and redirect to url. PKCE is on by default.
    • exchangeCode({ code, codeVerifier?, nonce? }) — redeems the code, verifies the ID token, and resolves with { accessToken, expiresIn, tokenType, idToken?, claims?, refreshToken?, scope? }.
    • refreshTokens(refreshToken) — exchanges a refresh token (needs the offline_access scope) for fresh tokens; carries the newest refresh token forward.
  • TokenValidationError — thrown when a token is missing, malformed, or fails a check; map it to a 401. The underlying jose error is on .cause.

@sedibyte/auth/salesforce

  • createSalesforceProvider({ clientId, username, privateKey, loginUrl?, audience?, name?, tokenLifetimeSec? }) — creates a Salesforce JWT-bearer provider. clientId is the connected-app consumer key (iss), username the user to impersonate (sub), privateKey a PKCS#8 PEM RSA key. loginUrl defaults to https://login.salesforce.com (use https://test.salesforce.com for sandboxes), audience to loginUrl, name to "salesforce", and tokenLifetimeSec to 3600. The returned provider also exposes getInstanceUrl() — the instance_url from the last token response.
  • createSalesforceClient({ providerName?, apiVersion? }) — returns { restFetch, restJson, query }, resolving the org's instance_url from the registered provider. providerName defaults to "salesforce", apiVersion to "v60.0".
    • restFetch(path, init?) — authenticated fetch; path may be a versioned resource (sobjects/...), an instance-relative path (/services/...), or an absolute URL.
    • restJson(path, init?) — same, but parses JSON and throws on non-2xx.
    • query(soql) — runs a SOQL query and returns the parsed QueryResult.
  • createSalesforceLogin({ clientId, clientSecret?, redirectUri, loginUrl?, scope?, authUri?, tokenUri?, userInfoUri?, clockToleranceSec? }) — creates an interactive "Sign in with Salesforce" (Authorization Code + PKCE) flow. loginUrl defaults to https://login.salesforce.com, scope to openid profile email; clientSecret is optional when the connected app disables it. Returns { createAuthUrl, exchangeCode, refreshTokens, getUserInfo }.
    • createAuthUrl({ state?, nonce?, usePkce?, prompt?, loginHint?, scope? }) — returns { url, state, nonce, codeVerifier? }; PKCE is on by default.
    • exchangeCode({ code, codeVerifier?, nonce? }) — redeems the code, verifies the ID token, and resolves with { accessToken, tokenType, instanceUrl?, id?, idToken?, claims?, refreshToken?, scope? }.
    • refreshTokens(refreshToken) — exchanges a refresh token (needs the refresh_token scope) for a fresh access token.
    • getUserInfo(accessToken) — reads the user's /userinfo profile.
  • createSalesforceValidator({ audience, issuer?, jwksUri?, clockToleranceSec? }) — returns { validateToken(token) } for verifying inbound Salesforce ID tokens. issuer defaults to https://login.salesforce.com, jwksUri to {issuer}/id/keys, clockToleranceSec to 300.
    • validateToken(token) — verifies RS256 signature, issuer, audience, and exp/nbf. Resolves with { header, claims } or rejects with a TokenValidationError. Accepts a raw JWT or a Bearer … header value.
  • TokenValidationError — thrown when a token is missing, malformed, or fails a check; map it to a 401. The underlying jose error is on .cause.

@sedibyte/auth/discord

  • createDiscordProvider({ clientId, clientSecret, grantType?, scope?, refreshToken?, name?, apiBase? }) — creates a Discord OAuth 2.0 provider. grantType defaults to "client_credentials" (with scope defaulting to "identify"; accepts a string or array); pass grantType: "refresh_token" with a refreshToken to use the refresh flow instead. name defaults to "discord", apiBase to https://discord.com/api/v10. Tokens are sent as Bearer. The returned provider also exposes getRefreshToken() — the latest (rotating) refresh token, or undefined for the client-credentials grant.
  • createDiscordBotProvider({ token, name? }) — adapts a static bot token into the provider interface. name defaults to "discord". Tokens are sent as Bot and never expire (cached effectively forever).
  • createDiscordClient({ providerName?, apiBase?, authScheme? }) — returns { discordFetch, discordJson }. providerName defaults to "discord", apiBase to https://discord.com/api/v10. authScheme defaults to the registered provider's scheme (Bot or Bearer), falling back to Bearer.
    • discordFetch(path, init?) — authenticated fetch; path may be relative or an absolute URL.
    • discordJson(path, init?) — same, but parses JSON and throws on non-2xx.
  • createDiscordLogin({ clientId, clientSecret, redirectUri, scope?, authUri?, apiBase? }) — creates an interactive "Sign in with Discord" login (OAuth 2.0 Authorization Code flow with PKCE). scope defaults to identify email, authUri to https://discord.com/oauth2/authorize, apiBase to https://discord.com/api/v10. Returns { createAuthUrl, exchangeCode, refreshTokens, getProfile }.
    • createAuthUrl({ state?, usePkce?, prompt?, scope? }) — builds the consent URL; returns { url, state, codeVerifier? }. PKCE is on by default; persist state and codeVerifier for the callback.
    • exchangeCode({ code, codeVerifier? }) — redeems the code for { accessToken, tokenType, expiresIn, refreshToken?, scope? }. Discord rotates the refresh token.
    • refreshTokens(refreshToken) — exchanges a stored refresh token for a fresh access token; carries the rotated refresh token forward.
    • getProfile(accessToken) — reads the signed-in user from GET /users/@me ({ id, username, global_name?, email?, ... }; email needs the email scope).

@sedibyte/auth/google

  • createGoogleProvider({ clientEmail, privateKey, scope, subject?, tokenUri?, name? }) — creates a Google service-account JWT-bearer provider. clientEmail is the service account's email (iss), privateKey a PKCS#8 PEM RSA key (both from the service-account JSON key), scope a space-delimited string or array (required). Pass subject to impersonate a user via domain-wide delegation. tokenUri defaults to https://oauth2.googleapis.com/token, name to "google".
  • createGoogleClient({ providerName?, apiBase? }) — returns { googleFetch, googleJson }. providerName defaults to "google", apiBase to https://www.googleapis.com.
    • googleFetch(path, init?) — authenticated fetch; path may be relative or an absolute URL.
    • googleJson(path, init?) — same, but parses JSON and throws on non-2xx.
  • createGoogleLogin({ clientId, clientSecret, redirectUri, scope?, hostedDomain?, authUri?, tokenUri?, clockToleranceSec? }) — drives interactive "Sign in with Google" (OAuth 2.0 Authorization Code flow, PKCE by default). scope defaults to openid email profile. Returns { createAuthUrl, exchangeCode, refreshTokens }.
    • createAuthUrl({ state?, nonce?, usePkce?, accessType?, prompt?, loginHint?, includeGrantedScopes?, scope? }) — returns { url, state, nonce, codeVerifier? }; persist state/nonce/codeVerifier and redirect the user to url. state, nonce, and the PKCE verifier are generated when omitted.
    • exchangeCode({ code, codeVerifier?, nonce? }) — redeems the callback code for { accessToken, expiresIn, tokenType, idToken?, claims?, refreshToken?, scope? }, verifying the ID token (and nonce when given). Rejects with TokenValidationError on a bad ID token.
    • refreshTokens(refreshToken) — exchanges a stored refresh token for a fresh access/ID token (same result shape).
  • createGoogleValidator({ audience, issuer?, hostedDomain?, jwksUri?, clockToleranceSec? }) — returns { validateToken(token) } for verifying inbound Google ID tokens. audience is your OAuth client ID (required). issuer defaults to Google's two issuer strings, jwksUri to https://www.googleapis.com/oauth2/v3/certs, and clockToleranceSec to 300. Pass hostedDomain to require a Workspace hd claim.
    • validateToken(token) — verifies RS256 signature, issuer, audience, and exp/nbf. Resolves with { header, claims } or rejects with a TokenValidationError. Accepts a raw JWT or a Bearer … header value.
  • TokenValidationError — thrown when a token is missing, malformed, or fails a check; map it to a 401. The underlying jose error is on .cause.

@sedibyte/auth/apple

  • createAppleLogin({ clientId, teamId, keyId, privateKey, redirectUri, scope?, clientSecretLifetimeSec?, authUri?, tokenUri?, clockToleranceSec? }) — drives interactive "Sign in with Apple" (OAuth 2.0 Authorization Code flow, PKCE by default). clientId is your Services ID, teamId/keyId the 10-char Apple identifiers, privateKey the .p8 key (PKCS#8 PEM). scope defaults to name email; clientSecretLifetimeSec to 300 (capped at Apple's six-month max). Returns { createAuthUrl, exchangeCode, refreshTokens, generateClientSecret }.
    • createAuthUrl({ state?, nonce?, usePkce?, responseMode?, scope? }) — returns { url, state, nonce, codeVerifier? }; persist state/nonce/codeVerifier and redirect the user to url. responseMode defaults to form_post when a scope is requested (Apple's rule) and query otherwise. state, nonce, and the PKCE verifier are generated when omitted.
    • exchangeCode({ code, codeVerifier?, nonce? }) — signs a fresh client secret and redeems the callback code for { accessToken, expiresIn, tokenType, idToken?, claims?, refreshToken? }, verifying the ID token (and nonce when given). Rejects with TokenValidationError on a bad ID token.
    • refreshTokens(refreshToken) — exchanges a stored refresh token for a fresh access/ID token (same result shape); Apple returns the same refresh token, carried forward for you.
    • generateClientSecret() — mints the ES256 client-secret JWT on its own; rarely needed directly.
  • parseUserField(user) — parses the one-time user form field Apple posts on first sign-in into { firstName?, lastName?, email? }, or undefined when absent/unparseable (every sign-in after the first).
  • createAppleValidator({ audience, issuer?, jwksUri?, clockToleranceSec? }) — returns { validateToken(token) } for verifying inbound Apple ID tokens. audience is your Services ID or the app's bundle ID (required). issuer defaults to https://appleid.apple.com, jwksUri to https://appleid.apple.com/auth/keys, and clockToleranceSec to 300.
    • validateToken(token) — verifies RS256 signature, issuer, audience, and exp/nbf. Resolves with { header, claims } or rejects with a TokenValidationError. Accepts a raw JWT or a Bearer … header value.
  • TokenValidationError — thrown when a token is missing, malformed, or fails a check; map it to a 401. The underlying jose error is on .cause.

@sedibyte/auth/facebook

  • createFacebookLogin({ clientId, clientSecret, redirectUri, scope?, graphVersion?, authUri?, tokenUri?, graphBase?, clockToleranceSec? }) — drives interactive "Login with Facebook" (OAuth 2.0 Authorization Code flow, PKCE by default). clientId/clientSecret are your App ID and App Secret. scope defaults to public_profile email (add openid for an ID token); graphVersion defaults to v21.0. Returns { createAuthUrl, exchangeCode, getProfile, exchangeForLongLivedToken }.
    • createAuthUrl({ state?, nonce?, usePkce?, authType?, display?, scope? }) — returns { url, state, nonce, codeVerifier? }; persist state/nonce/codeVerifier and redirect the user to url. The nonce is added to the URL only when the openid scope is requested. state, nonce, and the PKCE verifier are generated when omitted.
    • exchangeCode({ code, codeVerifier?, nonce? }) — redeems the callback code for { accessToken, expiresIn?, tokenType?, idToken?, claims? }. When an ID token is returned it is verified (and nonce checked when given); rejects with TokenValidationError on a bad ID token.
    • getProfile(accessToken, fields?) — reads GET /me (signed with an appsecret_proof) and returns the parsed profile. fields defaults to id,name,email (string or array).
    • exchangeForLongLivedToken(accessToken) — trades a short-lived token for a long-lived one (~60 days) via the fb_exchange_token grant; returns { accessToken, expiresIn?, tokenType? }.
  • createFacebookValidator({ audience, issuer?, jwksUri?, clockToleranceSec? }) — returns { validateToken(token) } for verifying inbound Facebook ID tokens. audience is your App ID (required). issuer defaults to https://www.facebook.com, jwksUri to https://www.facebook.com/.well-known/oauth/openid/jwks/, and clockToleranceSec to 300.
    • validateToken(token) — verifies RS256 signature, issuer, audience, and exp/nbf. Resolves with { header, claims } or rejects with a TokenValidationError. Accepts a raw JWT or a Bearer … header value.
  • TokenValidationError — thrown when a token is missing, malformed, or fails a check; map it to a 401. The underlying jose error is on .cause.

@sedibyte/auth/okta

  • createOktaLogin({ issuer, clientId, clientSecret, redirectUri, scope?, authUri?, tokenUri?, userInfoUri?, clockToleranceSec? }) — drives interactive "Sign in with Okta" (OAuth 2.0 Authorization Code flow, PKCE by default). issuer is your Okta authorization server's base URL (e.g. https://your-org.okta.com/oauth2/default); the authorize, token, and userinfo endpoints are derived from it. scope defaults to openid email profile (add offline_access for a refresh token). Returns { createAuthUrl, exchangeCode, refreshTokens, getUserInfo }.
    • createAuthUrl({ state?, nonce?, usePkce?, prompt?, loginHint?, idp?, scope? }) — returns { url, state, nonce, codeVerifier? }; persist state/nonce/codeVerifier and redirect the user to url. state, nonce, and the PKCE verifier are generated when omitted.
    • exchangeCode({ code, codeVerifier?, nonce? }) — redeems the callback code for { accessToken, expiresIn, tokenType, idToken?, claims?, refreshToken?, scope? }, verifying the ID token (and nonce when given). Rejects with TokenValidationError on a bad ID token.
    • refreshTokens(refreshToken) — exchanges a stored refresh token for a fresh access/ID token (same result shape); Okta may rotate the refresh token, and the newest value is carried forward on the result.
    • getUserInfo(accessToken) — reads the /v1/userinfo endpoint and returns the parsed profile ({ sub, name?, email?, ... }).
  • createOktaValidator({ issuer, audience, jwksUri?, clockToleranceSec? }) — returns { validateToken(token) } for verifying inbound Okta ID tokens. issuer (org-specific) and audience (your client ID) are both required. jwksUri defaults to {issuer}/v1/keys, and clockToleranceSec to 300.
    • validateToken(token) — verifies RS256 signature, issuer, audience, and exp/nbf. Resolves with { header, claims } or rejects with a TokenValidationError. Accepts a raw JWT or a Bearer … header value.
  • TokenValidationError — thrown when a token is missing, malformed, or fails a check; map it to a 401. The underlying jose error is on .cause.

@sedibyte/auth/github

  • createGitHubLogin({ clientId, clientSecret, redirectUri, scope?, authUri?, tokenUri?, apiBaseUrl?, userAgent? }) — drives interactive "Sign in with GitHub" (OAuth 2.0 Authorization Code flow). GitHub is not OIDC: there's no ID token and no PKCE, so state is the only round-trip check. scope defaults to read:user user:email; authUri/tokenUri/apiBaseUrl default to github.com's endpoints (override for Enterprise Server). Returns { createAuthUrl, exchangeCode, getProfile, getEmails, refreshTokens }.
    • createAuthUrl({ state?, allowSignup?, login?, scope? }) — returns { url, state }; persist state and redirect the user to url. state is generated when omitted.
    • exchangeCode({ code }) — redeems the callback code for { accessToken, tokenType?, scope?, expiresIn?, refreshToken?, refreshTokenExpiresIn? }. GitHub reports OAuth errors with an HTTP 200 body; this detects them and rejects with TokenValidationError.
    • getProfile(accessToken) — reads GET /user and returns the parsed profile ({ id, login, name?, email?, ... }).
    • getEmails(accessToken) — reads GET /user/emails and returns { email, primary, verified, visibility }[] (needs the user:email or read:user scope). Use this when getProfile returns a null email.
    • refreshTokens(refreshToken) — exchanges a refresh token for a fresh access token (only meaningful when the app uses expiring tokens); GitHub rotates the refresh token, and the newest value is carried forward on the result.
  • createGitHubValidator({ clientId, clientSecret, apiBaseUrl?, userAgent? }) — returns { validateToken(token) } for verifying inbound GitHub access tokens. clientId/clientSecret are both required (the check endpoint authenticates with the app's own credentials). apiBaseUrl defaults to https://api.github.com.
    • validateToken(token) — introspects the opaque token against POST /applications/{client_id}/token (one network call). Resolves with { user, scopes, expiresAt?, raw } or rejects with a TokenValidationError when GitHub reports the token unknown/invalid for this app. Accepts a raw token or a Bearer …/token … header value.
  • TokenValidationError — thrown when a token is missing or GitHub rejects it; map it to a 401.

Adding a provider

Create providers/<name>/provider.js exporting a factory that returns { name, requestToken() }:

export function createAcmeProvider({ clientId, clientSecret }) {
  return {
    name: "acme",
    async requestToken() {
      // ...fetch a token...
      return { accessToken, expiresIn }; // seconds
    },
  };
}

The registry, caching, and expiry-skew logic are inherited for free — nothing in auth.js changes.