@authris/sdk
v3.0.3
Published
Authris SDK — OIDC RP helpers (authorize, exchange, refresh, end-session, verify), Device Flow + PAT for CLIs, Service Account (private_key_jwt) M2M client
Readme
@authris/sdk
Official TypeScript SDK for Authris. Three complementary roles:
- OIDC RP helpers — drive end-user sign-in: build the
/authorizeURL with PKCE + state + nonce, exchange the code, verify tokens, refresh, RP-Initiated logout. - Device Flow + PAT for CLIs — RFC 8628 device authorization plus long-lived Personal Access Tokens, with the same wire shape as access_tokens.
- Service Accounts (M2M) —
ServiceAccountClientsignsprivate_key_jwtclient assertions (RFC 7521/7523) and mints + caches short-livedclient_credentialsaccess tokens for backend-to-backend calls. - M2M Management API —
M2mManagementClientprovisions M2M resources, service accounts, keys, and audit records for product consoles.
npm install @authris/sdkThe code samples below are framework-agnostic pseudo-code.
req.session.*/res.redirect(...)follow Express conventions — substitute your own framework's session store and redirect helper. The SDK is stateless: it never persists user sessions or tokens for you (except the in-memory caches noted below).
Before you start: getting your values
Every snippet needs an issuer, a client_id, usually a client_secret, and your redirect URI. Here is exactly where each comes from.
Issuer URL — the #1 thing to get right
Authris issues tokens per team, so the issuer is keyed by your team slug (not the project slug):
https://<your-authris-gateway>/o/<team-slug>- The path segment is
/o/— not/t/, not/api/.... Discovery lives at…/o/<team-slug>/.well-known/openid-configuration. <team-slug>is your team's slug. Clients live inside projects inside a team, but the OIDC issuer is the team's — all clients in a team share one issuer.- Don't hand-assemble it. Open the client in the Authris Console → your client → Integration tab; it shows the exact Issuer, Discovery, and JWKS URLs with copy buttons. Copy the Issuer verbatim — that value is canonical for your deployment.
Quick check before wiring any code — this must return JSON, not a 404:
curl https://<your-authris-gateway>/o/<team-slug>/.well-known/openid-configurationWhere to create the client + register the redirect URI
In the Authris Console:
- Open (or create) a project under your team, then go to its Credentials page.
- New client → pick the app type:
- Web → confidential client (gets a
client_secret,client_secret_basicauth). - SPA / Android / iOS / Desktop → public client (PKCE only, no secret).
- Web → confidential client (gets a
- Register your redirect URI(s) on that client (e.g.
https://your-app.example.com/oauth/callback). Authris rejects anyredirect_uriat/authorizethat isn't registered here — an unregistered URI is the most common cause of an authorize failure. - For a confidential client, open the Secrets tab and generate a secret (shown once — store it).
- To enable Device Flow, tick
device_codeunder the client's Grant types.
Already using a framework auth library?
Authris is a standard OIDC provider, so you usually don't need this SDK for sign-in at all — point your library at the discovery URL:
- better-auth (
genericOAuthplugin) / Auth.js / NextAuth (custom OIDC provider): configurediscoveryUrl = https://<gateway>/o/<team-slug>/.well-known/openid-configuration, yourclientId,clientSecret, scopesopenid profile email(addoffline_accessfor refresh tokens), andpkce: true.
Reach for @authris/sdk directly when you need Device Flow, Service Accounts, PAT verification, or fine-grained control over the RP flow.
OIDC end-user sign-in
OidcClient holds the issuer + client identity and caches discovery + JWKS, so a constructed instance is cheap to reuse across requests.
import { OidcClient } from '@authris/sdk';
const oidc = new OidcClient({
issuer: 'https://gateway.example.com/o/acme', // ← /o/<team-slug>; copy from Console → Integration
clientId: 'YOUR_OAUTH_CLIENT_ID',
clientSecret: process.env.AUTHRIS_CLIENT_SECRET, // omit for public clients
});1. Build the authorize URL
buildAuthorizeUrl returns the URL plus the PKCE verifier, state, and nonce — the caller MUST persist those three in their session before redirecting, then read them back in the callback.
const { url, codeVerifier, state, nonce } = await oidc.buildAuthorizeUrl({
redirectUri: 'https://your-app.example.com/oauth/callback', // must be registered on the client
scope: ['openid', 'profile', 'email', 'offline_access'], // add 'offline_access' to get a refresh_token
});
// Stash the trio in your session, then redirect.
req.session.codeVerifier = codeVerifier;
req.session.state = state;
req.session.nonce = nonce;
res.redirect(url);Refresh tokens require
offline_access. Without that scope the token endpoint returns norefresh_token, andoidc.refresh(...)(below) has nothing to work with.
Client-hosted social buttons (idp hint)
Put your own "Sign in with Google / GitHub / …" buttons on your page and have each jump straight to that provider via Authris — skipping the Authris method picker. Pass idp:
// Your "Sign in with Google" button
const { url, codeVerifier, state, nonce } = await oidc.buildAuthorizeUrl({
redirectUri: 'https://your-app.example.com/oauth/callback',
scope: ['openid', 'profile', 'email'],
idp: 'google', // 'google' | 'github' | 'oidc:<key>'
});
res.redirect(url);The user takes one trip through the provider and lands back on your redirect_uri?code=... — exchange it exactly as below. If the team hasn't enabled the named provider, Authris falls back to its normal method picker (no error). Social login always involves one redirect to the provider, so there is no headless equivalent.
2. Exchange the code
In your callback handler, verify the state matches the stashed value (defends against CSRF) and exchange the code:
const tokens = await oidc.exchangeCode({
code: req.query.code,
redirectUri: 'https://your-app.example.com/oauth/callback',
codeVerifier: req.session.codeVerifier,
});
// → { access_token, id_token, refresh_token, expires_in, ... }exchangeCode does NOT validate the returned id_token — that step is separate so you can pass back the stashed nonce:
const claims = await oidc.verifyIdToken(tokens.id_token, { nonce: req.session.nonce });
console.log(claims.sub, claims.email);3. Refresh
Authris rotates refresh tokens on every use, so persist the returned refresh_token over the old one (the previous one is immediately invalidated — reuse is treated as theft).
const refreshed = await oidc.refresh(req.session.refreshToken);
req.session.refreshToken = refreshed.refresh_token;When you need retry-safe refresh, use OidcUserTokenManager below. It sends an idempotency_key on refresh and retries network transport failures with the same key.
4. Logout (RP-Initiated)
const logoutUrl = await oidc.buildEndSessionUrl({
idTokenHint: req.session.idToken,
postLogoutRedirectUri: 'https://your-app.example.com/bye',
});
res.redirect(logoutUrl);Alternative: headless password auth (client-hosted pages)
If you'd rather host your own sign-up / sign-in / forgot-password pages instead of redirecting to Authris, submit the credentials directly. These return the same OIDC tokens as the redirect flow (same sub, signing key, refresh rotation), so everything downstream — verifyIdToken, getUserInfo, refresh — is unchanged. Public SPA clients need only clientId (no secret).
// Register → tokens (register-then-login). Throws OidcClientError('email_exists') on a dupe.
const tokens = await oidc.signUp({ name: 'Ann', email: '[email protected]', password });
// Log in → tokens. Bad credentials throw OidcClientError('invalid_grant').
const tokens2 = await oidc.signInWithPassword({ email: '[email protected]', password });
// Forgot password → emails a reset link. Always resolves (no email enumeration).
// resetRedirectUri must exactly match one of the client's registered redirect_uris,
// otherwise the gateway falls back to the portal reset page.
await oidc.forgotPassword({ email: '[email protected]', resetRedirectUri: 'https://your-app.example.com/reset' });
// Reset password → uses the single-use token from the email link.
await oidc.resetPassword({ token: tokenFromEmail, newPassword });Headless password auth means your app handles the raw password — it bypasses SSO / social / passkey / MFA. Prefer the redirect flow above unless you specifically need an embedded login UI. Always serve these pages over HTTPS.
Verify access tokens / fetch userinfo / introspect / revoke
await oidc.verifyAccessToken(accessToken); // local JWT verification (JWKS)
await oidc.verifyTokenAuto(bearer); // JWT OR `authris_pat_*` — dispatches automatically
await oidc.getUserInfo(accessToken);
await oidc.introspect(refreshToken, 'refresh_token');
await oidc.revoke(refreshToken, 'refresh_token');Resource-server
audiencegotcha. Authris signs end-user access tokens withaud = "<issuer>/userinfo", andverifyAccessTokendefaults to checking exactly that. Service-account resource tokens use the requestedresourcevalue asaud. Pass your API audience explicitly:verifyAccessToken(token, { audience: 'https://api.example.com' }). Mismatched audience throwsOidcClientErrorwithcode = 'invalid_audience'.
verifyTokenAuto is the right entry point for resource servers that may receive either JWT access_tokens (interactive sessions) or PATs (CLI / scripts) on the same Authorization: Bearer header — see the CLI section below.
PAT verification needs a confidential client. PATs (
authris_pat_*) have no signature, soverifyTokenAutovalidates them via the introspection endpoint, which authenticates the caller withclientSecret. A public client (no secret) therefore cannot verify PATs —verifyTokenAutothrowsOidcClientErrorwithcode = 'missing_secret'. Use a confidential client on any resource server that accepts PATs.
Low-level PKCE primitives
Useful for split architectures (mint PKCE on the edge, exchange in the backend):
import { generatePkcePair, generateState, generateNonce, pkceChallengeFor } from '@authris/sdk';
const { codeVerifier, codeChallenge, codeChallengeMethod } = generatePkcePair();
const challenge = pkceChallengeFor(existingVerifier);Building a CLI tool (Device Flow + PAT)
Authris supports two long-lived credential models that make it cheap to build a gh-style CLI on top of your OAuth client:
- Device Flow (RFC 8628) — interactive login from a terminal. The CLI displays a short user code; the user visits a URL in their browser and approves; the CLI polls until it receives an
access_token+refresh_token. No client secret, no callback server. - Personal Access Tokens (PAT) — long-lived opaque tokens (prefix
authris_pat_) the user creates from the portal/settingspage. Same wire shape as access_tokens; users paste one into your CLI'slogin --tokencommand for non-interactive automation.
Make sure your OAuth client has device_code checked in the Grant types section of its Console configuration page before relying on either.
Device Flow (CLI side)
import { runDeviceFlow } from '@authris/sdk';
const tokens = await runDeviceFlow(
{
issuer: 'https://gateway.example.com/o/acme',
clientId: 'YOUR_PUBLIC_CLIENT_ID',
scope: 'openid profile email',
},
{
onPrompt: ({ verificationUriComplete, userCode }) => {
console.log(`\n Visit: ${verificationUriComplete}`);
console.log(` Code: ${userCode}\n`);
},
},
);
// → { accessToken, refreshToken, idToken, expiresIn, ... }The lower-level startDeviceFlow + pollDeviceToken are exported too if you need to drive the polling loop yourself.
Auto-refreshing the token (OidcUserTokenManager)
A user token set is a (access_token, refresh_token, expiresAt) triple. OidcUserTokenManager holds it for you, refreshes proactively a few seconds before expiry, deduplicates concurrent refreshes, retries refresh network failures with one idempotency key, and writes the rotated refresh_token back to a pluggable TokenStorage so it survives restarts.
import {
OidcClient,
OidcUserTokenManager,
runDeviceFlow,
type TokenStorage,
} from '@authris/sdk';
const oidc = new OidcClient({
issuer: 'https://gateway.example.com/o/acme',
clientId: 'YOUR_PUBLIC_CLIENT_ID',
});
// Storage backend is up to you — the SDK stays Node/edge/RN agnostic.
const fileStorage: TokenStorage = {
load: async () => /* read JSON from ~/.config/your-app/auth.json */,
save: async (t) => /* write JSON, mode 0o600 */,
clear: async () => /* unlink the file */,
};
const tokens = new OidcUserTokenManager({ oidc, storage: fileStorage });
// First run: bootstrap via Device Flow, hand the result to the manager.
if (!(await tokens.getTokens())) {
const fresh = await runDeviceFlow(
{ issuer: oidc.issuer, clientId: 'YOUR_PUBLIC_CLIENT_ID', scope: 'openid profile email offline_access' },
{
onPrompt: ({ verificationUriComplete, userCode }) => {
console.log(`Visit: ${verificationUriComplete}\nCode: ${userCode}`);
},
},
);
await tokens.setTokens(OidcUserTokenManager.fromTokenResponse(fresh));
}
// Every subsequent request just asks for a token — refresh is automatic.
await fetch('https://api.example.com/...', {
headers: { Authorization: `Bearer ${await tokens.getAccessToken()}` },
});Behaviour notes:
- PAT short-circuit: tokens starting with
authris_pat_are returned untouched, never refreshed (they have no refresh_token). - Concurrency: parallel
getAccessToken()calls during a refresh share one in-flight network request — no thundering herd. - Refresh retry: refresh transport failures retry 3 times by default with 5s, 10s, 15s waits. Set
refreshRetryCount: 0to disable. HTTP token errors such asinvalid_grantare not retried. - Idempotency: every logical refresh gets one
idempotency_key; all retries reuse it so a lost token response can be replayed by the gateway. - Storage write order: the rotated refresh_token is persisted before the in-memory state is updated, so a crash mid-refresh doesn't leave you with a token that's only on disk or only in memory.
- No setInterval: refresh is lazy on demand — works in serverless / single-shot CLI runs without leaking timers.
- On failure:
getAccessToken()throwsOidcClientErrorwithcode = 'invalid_grant'when refresh fails (RT revoked / expired). Catch it, calltokens.clear(), and re-run Device Flow.
Minimal TokenStorage (file-backed)
A 20-line atomic file storage is usually enough; reach for OS keychain only if you actually need it. 0600 mode + write-temp-then-rename keeps it safe from partial writes:
import { writeFileSync, readFileSync, mkdirSync, chmodSync, renameSync, unlinkSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { homedir } from 'node:os';
const PATH = join(homedir(), '.config', 'your-app', 'auth.json');
export const fileStorage: TokenStorage = {
async load() {
try { return JSON.parse(readFileSync(PATH, 'utf8')); } catch { return null; }
},
async save(tokens) {
mkdirSync(dirname(PATH), { recursive: true });
const tmp = `${PATH}.tmp`;
writeFileSync(tmp, JSON.stringify(tokens), { mode: 0o600 });
chmodSync(tmp, 0o600);
renameSync(tmp, PATH); // atomic on POSIX
},
async clear() {
try { unlinkSync(PATH); } catch { /* ok if missing */ }
},
};For OS-keychain storage (what gh does), wrap a library like keytar or @napi-rs/keyring against the same TokenStorage shape — the manager doesn't care.
Verifying PATs (server side)
A PAT arrives at your backend in the same header shape as a JWT access_token (Authorization: Bearer authris_pat_*). verifyTokenAuto accepts either kind and returns claims in JWTPayload shape so resource-server code stays uniform:
import { OidcClient, OidcClientError, isPatToken } from '@authris/sdk';
const oidc = new OidcClient({
issuer: 'https://gateway.example.com/o/acme',
clientId: 'YOUR_OAUTH_CLIENT_ID',
clientSecret: process.env.AUTHRIS_CLIENT_SECRET, // required — PAT introspect authenticates the caller
});
try {
const claims = await oidc.verifyTokenAuto(bearer);
// claims.sub, claims.scope, claims.client_id are populated for both PATs and JWTs.
} catch (err) {
if (err instanceof OidcClientError && err.code === 'invalid_token') {
// unknown / revoked / expired / scoped to a different client
}
}Use isPatToken(bearer) (true when the token starts with the authris_pat_ prefix, also exported as PAT_TOKEN_PREFIX) if you need to branch before verification — e.g. tag the audit log differently for PAT vs interactive sessions, route your own opaque tokens elsewhere, or short-circuit when your endpoint only allows one kind.
Service Accounts (M2M)
Service accounts are non-user clients that authenticate to the token endpoint with a private_key_jwt client assertion (RFC 7521/7523). The client holds the PKCS#8 private key in-process; Authris holds only the matching public key. Tokens are short-lived JWT access_tokens (sub = sa:<client_id>), suitable for backend-to-backend calls.
Mint + cache access tokens
import { ServiceAccountClient } from '@authris/sdk';
const saPrivateKeyPem = process.env.AUTHRIS_SA_PRIVATE_KEY;
if (!saPrivateKeyPem) throw new Error('AUTHRIS_SA_PRIVATE_KEY is required');
const sa = new ServiceAccountClient({
issuer: 'https://gateway.example.com/o/acme',
clientId: 'YOUR_SERVICE_ACCOUNT_CLIENT_ID',
privateKeyPem: saPrivateKeyPem, // PKCS#8 PEM
keyId: process.env.AUTHRIS_SA_KEY_ID, // kid header (optional but recommended)
});
// On every outbound request to a resource server:
const token = await sa.getAccessTokenFor({
resource: 'https://api.example.com',
scopes: ['files:read', 'files:write'],
});
await fetch('https://api.example.com/...', {
headers: { Authorization: `Bearer ${token}` },
});The first call mints (signs a fresh client_assertion JWT and POSTs grant_type=client_credentials to the token endpoint); subsequent calls hit an in-memory cache keyed by resource + scopes and refresh proactively when within 30 s of expiry. Concurrent callers during a refresh share one in-flight request — no thundering herd.
getAccessToken() without arguments is still available for legacy service-account tokens whose audience is the service account client_id. Resource servers should prefer getAccessTokenFor({ resource, scopes }).
Provisioning from a product console
Use M2mManagementClient when your product, such as storemux, needs to create credentials for its own customers or downstream projects. The caller itself is a management service account and must hold the corresponding Authris management scopes:
authris:resources:writeto create resource audiences and scope catalogs.authris:projects:writeto create or disable projects.authris:clients:writeto create or disable service accounts.authris:credentials:writeto create, rotate, expire, or disable keys.authris:audit:readto inspect management audit events.
import { M2mManagementClient, ServiceAccountClient } from '@authris/sdk';
const managementClientId = process.env.AUTHRIS_MGMT_CLIENT_ID;
const managementKeyId = process.env.AUTHRIS_MGMT_KEY_ID;
const managementPrivateKeyPem = process.env.AUTHRIS_MGMT_PRIVATE_KEY;
if (!managementClientId) throw new Error('AUTHRIS_MGMT_CLIENT_ID is required');
if (!managementKeyId) throw new Error('AUTHRIS_MGMT_KEY_ID is required');
if (!managementPrivateKeyPem) throw new Error('AUTHRIS_MGMT_PRIVATE_KEY is required');
const managementSa = new ServiceAccountClient({
issuer: 'https://gateway.example.com/o/storemux',
clientId: managementClientId,
keyId: managementKeyId,
privateKeyPem: managementPrivateKeyPem,
});
const management = new M2mManagementClient({
baseUrl: 'https://gateway.example.com/api/t/storemux',
fetchImpl: null, // use global fetch
getAccessToken: () => managementSa.getAccessTokenFor({
resource: 'https://gateway.example.com/api/t/storemux',
scopes: [
'authris:resources:write',
'authris:projects:write',
'authris:clients:write',
'authris:credentials:write',
'authris:audit:read',
],
}),
});
const project = await management.createProject({
name: 'Project A',
slug: 'project-a',
description: 'Project A in storemux',
logoUri: null,
homepageUri: null,
});
const filesResource = await management.createResource({
name: 'Storemux Files',
audience: 'https://api.storemux.example/files',
description: 'Storemux file management API',
scopes: ['files:read', 'files:write'],
});
const projectA = await management.createServiceAccount({
projectId: project.id,
name: 'Project A backend',
description: 'Project A calls storemux files API',
allowedScopes: [],
resourceGrants: [{ resourceId: filesResource.id, scopes: ['files:read', 'files:write'] }],
});
const key = await management.createServiceAccountKey({
clientId: projectA.id,
name: 'project-a-prod',
expiresAt: '2026-12-31T00:00:00.000Z',
});
// Persist key.privateKeyPem in Project A's secret store now. It is returned once.Project A then exchanges that key for a short-lived access token scoped to the storemux files resource:
const projectASa = new ServiceAccountClient({
issuer: 'https://gateway.example.com/o/storemux',
clientId: projectA.id,
keyId: key.kid,
privateKeyPem: key.privateKeyPem,
});
const accessToken = await projectASa.getAccessTokenFor({
resource: 'https://api.storemux.example/files',
scopes: ['files:read'],
});Lists and later reads never return historical private keys:
await management.listServiceAccountKeys({ clientId: projectA.id });
await management.listAuditEvents({ limit: 50 });Provisioning the keypair
The Console generates the keypair when you create a service account and shows the private key once. Save it as a single-line PKCS#8 PEM (with the BEGIN PRIVATE KEY / END PRIVATE KEY headers) in your secret store; Authris persists only the public half and rejects the key after that initial reveal. Rotate by adding a new key under the SA in Console and revoking the old one.
Server-side verification
A service-account access_token is a regular JWT signed by the issuer's JWKS. Resource tokens carry aud = resource, scope, team_id, client_id, and sub = sa:<client_id>, so you can split SA traffic from user traffic in the same handler:
const claims = await oidc.verifyAccessToken(bearer, { audience: 'https://api.example.com' });
if (typeof claims.sub === 'string' && claims.sub.startsWith('sa:')) {
// service account — no user identity; authorize by client_id + scope
} else {
// end-user access_token — authorize by the user's sub
}CLIENT_ASSERTION_TYPE
Exported for callers that build the client_assertion themselves (e.g. signing in a different language and POSTing manually):
import { CLIENT_ASSERTION_TYPE } from '@authris/sdk';
// = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"Error handling
Every failure throws OidcClientError (or DeviceFlowError / ServiceAccountClientError for those clients) with a stable .code you can branch on, plus .message and an optional .cause:
import { OidcClientError } from '@authris/sdk';
try {
await oidc.verifyTokenAuto(bearer);
} catch (err) {
if (err instanceof OidcClientError && err.code === 'invalid_token') reject();
}OidcClientError.code values:
| code | meaning |
|------|---------|
| invalid_issuer | discovery document's issuer didn't match the configured issuer |
| invalid_audience | token aud didn't match the expected audience (see the audience gotcha above) |
| expired_token | token is past its exp |
| invalid_signature | JWT signature failed JWKS verification |
| invalid_token | token unknown / revoked / malformed / scoped to a different client (also: inactive PAT) |
| invalid_grant | code or refresh_token rejected at the token endpoint (e.g. reused / expired RT) |
| missing_secret | a clientSecret-requiring call (introspect, PAT verify, revoke) was made on a client with no secret |
| discovery_failed | /.well-known/openid-configuration couldn't be fetched/parsed (wrong issuer URL → check /o/<team-slug>) |
| jwks_failed | the JWKS endpoint couldn't be fetched/parsed |
| http_error | a token/userinfo/introspect/revoke HTTP call returned a non-2xx the SDK didn't map more specifically |
DeviceFlowError.code adds the RFC 8628 set (authorization_pending is absorbed internally; you'll see access_denied, expired_token, slow_down-handled, aborted, etc.).
Versioning
This documents @authris/sdk v3.x. The package follows semver — pin a major (^3) and review the changelog before bumping across majors, as the client surface (e.g. OidcClient options) can change between major versions.
