@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).
Maintainers
Keywords
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 itDiscord 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 (orundefined).
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.namedefaults to"microsoft",scopetohttps://graph.microsoft.com/.default.createGraphClient({ providerName?, graphBase? })— returns{ graphFetch, graphJson }.providerNamedefaults to"microsoft",graphBaseto the Graph v1.0 endpoint.graphFetch(path, init?)— authenticatedfetch;pathmay 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.authoritydefaults tohttps://login.microsoftonline.com,issuerto the v2.0 and v1.0 issuers for the tenant,jwksUrito the tenant's v2.0 signing keys, andclockToleranceSecto300.validateToken(token)— verifies RS256 signature, issuer, audience, andexp/nbf. Resolves with{ header, claims }or rejects with aTokenValidationError. Accepts a raw JWT or aBearer …header value.
createMicrosoftLogin({ tenantId, clientId, clientSecret?, redirectUri, scope?, authority?, authUri?, tokenUri?, issuer?, clockToleranceSec? })— creates an interactive "Sign in with Microsoft" (Authorization Code + PKCE) flow.scopedefaults toopenid profile email;clientSecretis 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 tourl. 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 theoffline_accessscope) 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 underlyingjoseerror is on.cause.
@sedibyte/auth/salesforce
createSalesforceProvider({ clientId, username, privateKey, loginUrl?, audience?, name?, tokenLifetimeSec? })— creates a Salesforce JWT-bearer provider.clientIdis the connected-app consumer key (iss),usernamethe user to impersonate (sub),privateKeya PKCS#8 PEM RSA key.loginUrldefaults tohttps://login.salesforce.com(usehttps://test.salesforce.comfor sandboxes),audiencetologinUrl,nameto"salesforce", andtokenLifetimeSecto3600. The returned provider also exposesgetInstanceUrl()— theinstance_urlfrom the last token response.createSalesforceClient({ providerName?, apiVersion? })— returns{ restFetch, restJson, query }, resolving the org'sinstance_urlfrom the registered provider.providerNamedefaults to"salesforce",apiVersionto"v60.0".restFetch(path, init?)— authenticatedfetch;pathmay 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 parsedQueryResult.
createSalesforceLogin({ clientId, clientSecret?, redirectUri, loginUrl?, scope?, authUri?, tokenUri?, userInfoUri?, clockToleranceSec? })— creates an interactive "Sign in with Salesforce" (Authorization Code + PKCE) flow.loginUrldefaults tohttps://login.salesforce.com,scopetoopenid profile email;clientSecretis 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 therefresh_tokenscope) for a fresh access token.getUserInfo(accessToken)— reads the user's/userinfoprofile.
createSalesforceValidator({ audience, issuer?, jwksUri?, clockToleranceSec? })— returns{ validateToken(token) }for verifying inbound Salesforce ID tokens.issuerdefaults tohttps://login.salesforce.com,jwksUrito{issuer}/id/keys,clockToleranceSecto300.validateToken(token)— verifies RS256 signature, issuer, audience, andexp/nbf. Resolves with{ header, claims }or rejects with aTokenValidationError. Accepts a raw JWT or aBearer …header value.
TokenValidationError— thrown when a token is missing, malformed, or fails a check; map it to a 401. The underlyingjoseerror is on.cause.
@sedibyte/auth/discord
createDiscordProvider({ clientId, clientSecret, grantType?, scope?, refreshToken?, name?, apiBase? })— creates a Discord OAuth 2.0 provider.grantTypedefaults to"client_credentials"(withscopedefaulting to"identify"; accepts a string or array); passgrantType: "refresh_token"with arefreshTokento use the refresh flow instead.namedefaults to"discord",apiBasetohttps://discord.com/api/v10. Tokens are sent asBearer. The returned provider also exposesgetRefreshToken()— the latest (rotating) refresh token, orundefinedfor the client-credentials grant.createDiscordBotProvider({ token, name? })— adapts a static bot token into the provider interface.namedefaults to"discord". Tokens are sent asBotand never expire (cached effectively forever).createDiscordClient({ providerName?, apiBase?, authScheme? })— returns{ discordFetch, discordJson }.providerNamedefaults to"discord",apiBasetohttps://discord.com/api/v10.authSchemedefaults to the registered provider's scheme (BotorBearer), falling back toBearer.discordFetch(path, init?)— authenticatedfetch;pathmay 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).scopedefaults toidentify email,authUritohttps://discord.com/oauth2/authorize,apiBasetohttps://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; persiststateandcodeVerifierfor 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 fromGET /users/@me({ id, username, global_name?, email?, ... };emailneeds theemailscope).
@sedibyte/auth/google
createGoogleProvider({ clientEmail, privateKey, scope, subject?, tokenUri?, name? })— creates a Google service-account JWT-bearer provider.clientEmailis the service account's email (iss),privateKeya PKCS#8 PEM RSA key (both from the service-account JSON key),scopea space-delimited string or array (required). Passsubjectto impersonate a user via domain-wide delegation.tokenUridefaults tohttps://oauth2.googleapis.com/token,nameto"google".createGoogleClient({ providerName?, apiBase? })— returns{ googleFetch, googleJson }.providerNamedefaults to"google",apiBasetohttps://www.googleapis.com.googleFetch(path, init?)— authenticatedfetch;pathmay 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).scopedefaults toopenid email profile. Returns{ createAuthUrl, exchangeCode, refreshTokens }.createAuthUrl({ state?, nonce?, usePkce?, accessType?, prompt?, loginHint?, includeGrantedScopes?, scope? })— returns{ url, state, nonce, codeVerifier? }; persiststate/nonce/codeVerifierand redirect the user tourl.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 (andnoncewhen given). Rejects withTokenValidationErroron 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.audienceis your OAuth client ID (required).issuerdefaults to Google's two issuer strings,jwksUritohttps://www.googleapis.com/oauth2/v3/certs, andclockToleranceSecto300. PasshostedDomainto require a Workspacehdclaim.validateToken(token)— verifies RS256 signature, issuer, audience, andexp/nbf. Resolves with{ header, claims }or rejects with aTokenValidationError. Accepts a raw JWT or aBearer …header value.
TokenValidationError— thrown when a token is missing, malformed, or fails a check; map it to a 401. The underlyingjoseerror 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).clientIdis your Services ID,teamId/keyIdthe 10-char Apple identifiers,privateKeythe.p8key (PKCS#8 PEM).scopedefaults toname email;clientSecretLifetimeSecto300(capped at Apple's six-month max). Returns{ createAuthUrl, exchangeCode, refreshTokens, generateClientSecret }.createAuthUrl({ state?, nonce?, usePkce?, responseMode?, scope? })— returns{ url, state, nonce, codeVerifier? }; persiststate/nonce/codeVerifierand redirect the user tourl.responseModedefaults toform_postwhen a scope is requested (Apple's rule) andqueryotherwise.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 (andnoncewhen given). Rejects withTokenValidationErroron 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-timeuserform field Apple posts on first sign-in into{ firstName?, lastName?, email? }, orundefinedwhen absent/unparseable (every sign-in after the first).createAppleValidator({ audience, issuer?, jwksUri?, clockToleranceSec? })— returns{ validateToken(token) }for verifying inbound Apple ID tokens.audienceis your Services ID or the app's bundle ID (required).issuerdefaults tohttps://appleid.apple.com,jwksUritohttps://appleid.apple.com/auth/keys, andclockToleranceSecto300.validateToken(token)— verifies RS256 signature, issuer, audience, andexp/nbf. Resolves with{ header, claims }or rejects with aTokenValidationError. Accepts a raw JWT or aBearer …header value.
TokenValidationError— thrown when a token is missing, malformed, or fails a check; map it to a 401. The underlyingjoseerror 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/clientSecretare your App ID and App Secret.scopedefaults topublic_profile email(addopenidfor an ID token);graphVersiondefaults tov21.0. Returns{ createAuthUrl, exchangeCode, getProfile, exchangeForLongLivedToken }.createAuthUrl({ state?, nonce?, usePkce?, authType?, display?, scope? })— returns{ url, state, nonce, codeVerifier? }; persiststate/nonce/codeVerifierand redirect the user tourl. Thenonceis added to the URL only when theopenidscope 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 (andnoncechecked when given); rejects withTokenValidationErroron a bad ID token.getProfile(accessToken, fields?)— readsGET /me(signed with anappsecret_proof) and returns the parsed profile.fieldsdefaults toid,name,email(string or array).exchangeForLongLivedToken(accessToken)— trades a short-lived token for a long-lived one (~60 days) via thefb_exchange_tokengrant; returns{ accessToken, expiresIn?, tokenType? }.
createFacebookValidator({ audience, issuer?, jwksUri?, clockToleranceSec? })— returns{ validateToken(token) }for verifying inbound Facebook ID tokens.audienceis your App ID (required).issuerdefaults tohttps://www.facebook.com,jwksUritohttps://www.facebook.com/.well-known/oauth/openid/jwks/, andclockToleranceSecto300.validateToken(token)— verifies RS256 signature, issuer, audience, andexp/nbf. Resolves with{ header, claims }or rejects with aTokenValidationError. Accepts a raw JWT or aBearer …header value.
TokenValidationError— thrown when a token is missing, malformed, or fails a check; map it to a 401. The underlyingjoseerror 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).issueris 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.scopedefaults toopenid email profile(addoffline_accessfor a refresh token). Returns{ createAuthUrl, exchangeCode, refreshTokens, getUserInfo }.createAuthUrl({ state?, nonce?, usePkce?, prompt?, loginHint?, idp?, scope? })— returns{ url, state, nonce, codeVerifier? }; persiststate/nonce/codeVerifierand redirect the user tourl.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 (andnoncewhen given). Rejects withTokenValidationErroron 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/userinfoendpoint 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) andaudience(your client ID) are both required.jwksUridefaults to{issuer}/v1/keys, andclockToleranceSecto300.validateToken(token)— verifies RS256 signature, issuer, audience, andexp/nbf. Resolves with{ header, claims }or rejects with aTokenValidationError. Accepts a raw JWT or aBearer …header value.
TokenValidationError— thrown when a token is missing, malformed, or fails a check; map it to a 401. The underlyingjoseerror 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, sostateis the only round-trip check.scopedefaults toread:user user:email;authUri/tokenUri/apiBaseUrldefault to github.com's endpoints (override for Enterprise Server). Returns{ createAuthUrl, exchangeCode, getProfile, getEmails, refreshTokens }.createAuthUrl({ state?, allowSignup?, login?, scope? })— returns{ url, state }; persiststateand redirect the user tourl.stateis 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 withTokenValidationError.getProfile(accessToken)— readsGET /userand returns the parsed profile ({ id, login, name?, email?, ... }).getEmails(accessToken)— readsGET /user/emailsand returns{ email, primary, verified, visibility }[](needs theuser:emailorread:userscope). Use this whengetProfilereturns anullemail.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/clientSecretare both required (the check endpoint authenticates with the app's own credentials).apiBaseUrldefaults tohttps://api.github.com.validateToken(token)— introspects the opaque token againstPOST /applications/{client_id}/token(one network call). Resolves with{ user, scopes, expiresAt?, raw }or rejects with aTokenValidationErrorwhen GitHub reports the token unknown/invalid for this app. Accepts a raw token or aBearer …/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.
