sdxc

[ Identity & security ]

@sdxc/auth

OAuth 2.0 and OpenID Connect client for any runtime that speaks Request and Response

npm add @sdxc/auth
pnpm add @sdxc/auth
yarn add @sdxc/auth
bun add @sdxc/auth
Depends on
@remix-run/data-schema
Used by
uptime, auth-saas, reader, blog

OAuth 2.0 and OpenID Connect client for any runtime that speaks Request and Response.

The browser login, the app acting as itself, the API other clients call, and the provider's management API all share one Issuer, so the discovery document and key set are fetched once however many roles an app plays. Every protocol violation arrives as one AuthError with a closed code.

Installation

npm add @sdxc/auth

The core entry points need only fetch. The @sdxc/auth/remix/* entry points need remix v3, an optional peer dependency.

Entry pointWhat it holds
@sdxc/auth/issuerIssuer: discovery document, key set, and the shared cache
@sdxc/auth/relying-partyRelyingParty: the browser login, callback, and logout
@sdxc/auth/auth-sessionAuthSession: the token set a login leaves in the session
@sdxc/auth/service-clientServiceClient: client-credentials tokens, introspection, revocation
@sdxc/auth/resource-serverResourceServer: bearer token verification and RFC 9728 metadata
@sdxc/auth/protected-resourceProtectedResource: finding an API's authorization server
@sdxc/auth/bearer-challengeparse and stringify for WWW-Authenticate: Bearer
@sdxc/auth/management-clientManagementClient: the provider's subject and tenant records
@sdxc/auth/id-tokenIdToken and AUTHENTICATION_METHODS
@sdxc/auth/access-tokenAccessToken
@sdxc/auth/auth-errorAuthError and AuthErrorCode
@sdxc/auth/remix/contextsessionOf and contextOf
@sdxc/auth/remix/schemessessionScheme, bearerScheme, bearerFailure
@sdxc/auth/remix/authorizationcreateAuthorization

Usage

Point at an issuer

import { Issuer } from "@sdxc/auth/issuer";

let issuer = Issuer.for("https://auth.example.com");

await issuer.metadata(); // the discovery document, checked against this issuer
await issuer.tokenEndpoint(); // URL

Issuer.for hands every caller with the same configuration the same instance, so every role built on it shares one copy of the documents.

Sign a person in

The three route methods take { request, session }, where session is any store with get, set, and unset.

import type { AuthSession } from "@sdxc/auth/auth-session";

import { RelyingParty } from "@sdxc/auth/relying-party";

let rp = new RelyingParty(issuer, {
	clientId: "my-app",
	clientSecret: CLIENT_SECRET,
	redirectUri: "https://app.example.com/auth/callback",
});

async function handle(request: Request, session: AuthSession.Store): Promise<Response> {
	let ctx = { request, session };
	let url = new URL(request.url);

	if (url.pathname === "/auth/login") {
		return await rp.authorize(ctx, { returnTo: url.searchParams.get("returnTo") });
	}

	if (url.pathname === "/auth/callback") {
		let grant = await rp.callback(ctx);
		await users.upsert(grant.subject, grant.profile);
		return Response.redirect(new URL(grant.returnTo, url.origin), 303);
	}

	return await rp.endSession(ctx, { returnTo: "/" });
}

authorize stores state, nonce, and a PKCE verifier and redirects to the provider. callback checks the response against them, verifies the ID token, and writes the token set to the session. endSession clears it and sends the browser to the provider's logout.

Read the session

import { AuthSession } from "@sdxc/auth/auth-session";

let auth = AuthSession.from(session); // null when signed out

auth.idToken.subject; // the stable id to key your records on
auth.accessToken?.has("reports:write"); // null for an opaque access token
if (auth.expired && auth.renewable) await auth.refresh(rp);
auth.clear(); // signs out, leaving every other session entry alone

Call an API as the app itself

import { ServiceClient } from "@sdxc/auth/service-client";

let service = new ServiceClient(issuer, { clientId: "my-worker", clientSecret: CLIENT_SECRET });

let token = await service.token({ resources: ["https://api.example.com"] });
await fetch("https://api.example.com/reports", { headers: { authorization: `Bearer ${token}` } });

A token is reused until it nears expiry, and concurrent callers share one grant.

Accept bearer tokens

import { ResourceServer } from "@sdxc/auth/resource-server";

let api = new ResourceServer(issuer, { audience: "https://api.example.com" });

let token = await api.verifyRequest(request); // null when no bearer token was sent
if (token === null) return new Response(null, { status: 401 });
if (!token.has("reports:write")) return new Response(null, { status: 403 });

API

@sdxc/auth/issuer

Issuer.for(url, options?) and new Issuer(url, options?)

for answers one shared instance per configuration for the life of the isolate, so pass it fixed configuration and use the constructor for anything that varies per request. Options:

  • identifier: the iss value, when it differs from url

  • discovery: "openid" (default) reads /.well-known/openid-configuration; "oauth" reads RFC 8414 /.well-known/oauth-authorization-server

  • cache: an Issuer.CacheSource sharing documents across isolates

  • metadata: a discovery document to use instead of fetching one, checked the same way

  • ttl: how long a document stays in the shared cache, "1 hour" by default

Instance members

  • url, metadata(), identifier() (the iss value)

  • keys(): a key resolver for JWT.verify that refetches once for an unknown kid

  • verifyIdToken(raw, { audience, algorithms?, clockTolerance? }): an IdToken verified against this issuer, for a token that arrived outside the browser flow

  • authorizationEndpoint(), tokenEndpoint(), jwksUri(), userInfoEndpoint(), endSessionEndpoint(), revocationEndpoint(), introspectionEndpoint(): a URL; an optional endpoint the provider does not advertise throws endpoint_unsupported

  • scopesSupported(), responseTypesSupported(), tokenEndpointAuthMethodsSupported(), acrValuesSupported(), codeChallengeMethodsSupported(): a string[], empty when unpublished

Issuer.CacheStore

Any string-keyed store with read(key), write(key, value, { ttl }), and fetch(key, load, { ttl }), each answering a Result from @sdxc/result. A @sdxc/cache adapter satisfies it. CacheSource is a store or a function answering one, called on every read.

@sdxc/auth/relying-party

new RelyingParty(issuer, options)

Options: clientId, clientSecret, redirectUri, scopes (["openid", "profile", "email"]), clientAuth ("client_secret_post" or "client_secret_basic"), userInfo ("never", "always", or "when-missing"), authorizationParams, tokenParams, mapProfile(claims, tokens), subject(claims), mfa (["mfa"]), algorithms, clockTolerance (60 seconds), fallbackReturnTo ("/"), and rateLimit. Extra params naming one the flow writes itself throw reserved_parameter.

rateLimit is { adapter, key(request) } with an @sdxc/rate-limit adapter; authorize spends one unit per login and throws a 429 Response when the budget is spent.

rp.authorize(ctx, options?): Promise<Response>

The 303 to the authorization endpoint. Options: returnTo (kept to the request's origin, else fallbackReturnTo), scopes, acrValues, maxAge (seconds or a duration string), prompt, and authorizationParams.

rp.callback(ctx): Promise<Grant>

Completes the login once per transaction and answers idToken, accessToken (null when not a JWT), accessTokenRaw, expiresAt, refreshToken, returnTo, subject, claims, and the mapped profile. Fails with an AuthError.

rp.endSession(ctx, options?): Promise<Response>

Clears the session and redirects to the provider's end-session endpoint with id_token_hint. With { redirect: false } it answers the URL instead.

Other members

  • verifyIdToken(raw): the callback's checks over a token in hand

  • exchangeRefreshToken(refreshToken): the renewed token set

  • revoke(token, { tokenTypeHint? }): RFC 7009 revocation

  • mfa(idToken): whether the configured mfa values appear in amr or acr

  • renew(auth): refreshes a session, answering null when it stays signed in and the AuthError (with the session cleared) when the provider refuses

  • issuer

@sdxc/auth/auth-session

AuthSession.from(store): AuthSession | null and AuthSession.write(store, tokens)

Reads or writes the token set under one session key. A stored value that fails validation reads as signed out.

Instance members

idToken, accessToken (null for an opaque token), refreshToken, tokens (a copy of the raw strings), expired (true within 30 seconds of the end, or when no end is known), renewable, refresh(client) (throws missing_refresh_token when not renewable), and clear().

@sdxc/auth/service-client

new ServiceClient(issuer, options)

Options: clientId, clientSecret, clientAuth, scope, tokenParams, cache (an Issuer.CacheStore shared across isolates), rateLimit (an @sdxc/rate-limit adapter keyed by client id), waitUntil, and expirationMargin ("30 seconds" of remaining life required to reuse a token).

Members

  • token({ resources?, scope? }): Promise<string>: each resource is sent as its own RFC 8707 resource parameter

  • introspect(token, { tokenType? }): RFC 7662; an unknown, expired, or revoked token answers active: false

  • revoke(token, { tokenType? }): RFC 7009; runs through waitUntil when configured

  • clientId, issuer

@sdxc/auth/resource-server

new ResourceServer(issuer, options)

Options: audience and/or resource (one is required), metadata (extra RFC 9728 members), realm, introspection (anything with introspect(token), such as a ServiceClient, to accept opaque tokens), and acceptUnscopedIntrospection (false). With only resource, a token's aud must name the resource.

Members

  • verifyRequest(request): the AccessToken, null when no bearer token was sent; throws invalid_token for a rejected one

  • verifyAccessToken(credential): the same checks over a token in hand

  • challenge({ error?, errorDescription?, scope? }?): the WWW-Authenticate value, with realm and resource_metadata when configured

  • metadata(): the RFC 9728 document, null without a resource

  • resource, metadataUrl, issuer

@sdxc/auth/protected-resource

ProtectedResource.discover(resource, options?)

Fetches an API's RFC 9728 metadata and answers Result<ProtectedResource, AuthError>. Options: match ("exact" or "prefix") and cache.

ProtectedResource.fromChallenge(response, requestedUrl, options?)

Follows the resource_metadata pointer of a 401 or 403, answering null in the Result when there is none.

ProtectedResource.requiredScopes(response): string[]

The scopes a refusal's Bearer challenges ask for.

resource.issuer(authorizationServer?, options?)

The Issuer for a listed authorization server (the first by default), read through RFC 8414 and then OpenID Connect discovery. resource.metadata holds the document; new ProtectedResource(metadata) wraps one already in hand.

@sdxc/auth/bearer-challenge

stringify(challenge: Partial<BearerChallenge>): string

Writes a Bearer challenge with every value quoted and escaped.

parse(header: string): Result<BearerChallenge[], ChallengeParseError>

Reads every Bearer challenge in a header that may list several schemes. Unrecognized or untyped parameters land in extensions; ChallengeParseError carries the offset.

@sdxc/auth/management-client

new ManagementClient(service, options?)

Reads the provider's records with a ServiceClient's tokens. Options: baseUrl (the issuer's URL by default), resources (the token's resource indicators), and apiVersion (sent as X-API-Version; admin.apiVersionReceived holds what the last response echoed).

ManagementClient.discover(api, credentials, options?)

Builds a client from the API's URL through its RFC 9728 and RFC 8414 metadata, answering Result<ManagementClient, AuthError>.

admin.fetchSubjectById(subjectId)

A Result with the Subject, SubjectNotFoundError, or ManagementError.

Tenant-scoped methods

Each takes a tenantId first and answers a Result failing with ManagementError or ManagementProblem. List methods answer a Page<T> (items, next, prev) and take { cursor?, limit? }. createTenantSubject, registerTenantClient, and rotateTenantClientSecret take a last { idempotencyKey }, sent as Idempotency-Key.

  • Subjects: fetchTenantSubjectById, createTenantSubject, updateTenantSubject (JSON Merge Patch), blockTenantSubject, unblockTenantSubject, deleteTenantSubject, addTenantSubjectIdentifier, verifyTenantSubjectIdentifier, removeTenantSubjectIdentifier

  • Sessions and credentials: listTenantSubjectSessions, revokeTenantSubjectSession, revokeAllTenantSubjectSessions, revokeTenantSubjectPasskey, forceTenantSubjectPasswordReset, resetTenantSubjectSecondFactor

  • Clients: listTenantClients, registerTenantClient, updateTenantClient (JSON Merge Patch), rotateTenantClientSecret, revokeTenantClientSecret, disableTenantClient, deleteTenantClient

  • Grants and audit: listTenantGrants, revokeTenantGrant, readTenantAuditPage

  • Tenant: fetchTenant, listTenantMembers, inviteTenantMember, updateTenantMemberRole, removeTenantMember, listTenantDomains, attachTenantDomain, fetchTenantDomainVerification, updateTenantMfaPolicy

Errors

  • ManagementError: code (unauthorized, rate_limited, provider_failed, request_failed, invalid_response) and status; ManagementError.is(error, code) narrows

  • SubjectNotFoundError: carries subjectId

  • ManagementProblem: an RFC 9457 body with type, title, status, detail, instance, and errors ({ pointer, code, message } per invalid field)

  • managementProblems: the API's problem types as an @sdxc/problem catalog

@sdxc/auth/id-token

IdToken

A @sdxc/jwt JWT with OpenID Connect claims: subject (always a string), nonce, authTime, sessionId, atHash, amr (a list), acr, name, email, emailVerified, username, and picture. Key records on subject; the profile claims can change at the provider.

AUTHENTICATION_METHODS

The RFC 8176 amr values by name, such as AUTHENTICATION_METHODS.Mfa === "mfa".

@sdxc/auth/access-token

AccessToken

An RFC 9068 JWT with scopes, clientId, issuedToService (sub equals client_id), and has(scope). AccessToken.tryDecode(raw) answers null for an opaque token instead of throwing.

@sdxc/auth/auth-error

AuthError

Carries code, providerError, and providerErrorDescription; AuthError.is(error, code) narrows a caught value. AuthErrorCode: discovery_failed, issuer_mismatch, endpoint_unsupported, jwks_failed, missing_transaction, state_mismatch, nonce_mismatch, authorization_failed, missing_code, token_request_failed, missing_id_token, invalid_token, at_hash_mismatch, acr_not_satisfied, max_age_not_satisfied, user_info_failed, missing_refresh_token, introspection_failed, revocation_failed, rate_limited, reserved_parameter.

@sdxc/auth/remix/context

sessionOf(ctx) and contextOf(ctx)

The remix/middleware/session session as an AuthSession.Store, and { request, session } for the RelyingParty route methods. Both throw when the session middleware has not run.

@sdxc/auth/remix/schemes

sessionScheme(rp, { verify, name? })

A remix/middleware/auth scheme resolving the stored session into verify(auth)'s identity, renewing an expired set first. A signed-out request falls through to the next scheme.

bearerScheme(api, { verify, name? })

Resolves the bearer token into verify(token, ctx)'s identity. A request without one falls through; a rejected one answers 401 with api.challenge({ error: "invalid_token" }).

bearerFailure(api, body?)

A requireAuth({ onFailure }) handler answering 401 with the server's challenge. body builds your own response; a WWW-Authenticate it sets is kept.

@sdxc/auth/remix/authorization

createAuthorization(options)

Options: login, signedIn ("/"), returnToParam ("returnTo"), and relyingParty, a function answering the mfa policy. Answers helpers that read the current request through remix/middleware/async-context:

  • currentSession(): the AuthSession, or throws a redirect to login with returnTo

  • anonymous(): throws a redirect to signedIn for a signed-in request

  • subject(), scope(name), authenticated(duration?), mfa(): values that answer null/false for a signed-out request, safe to call while rendering

Types

Each class carries its option and result types on a namespace of the same name: Issuer.Options, Issuer.Metadata, Issuer.CacheStore, Issuer.CacheSource, Issuer.CacheWriteOptions, Issuer.IdTokenVerification, Issuer.Discovery; RelyingParty.Options, Context, AuthorizeOptions, EndSessionOptions, Grant, GrantedTokens, Profile, RateLimit, RevokeOptions, TokenTypeHint, Transaction, ClientAuth, UserInfoMode, Prompt; AuthSession.Store, Tokens, Refreshed, Client; ServiceClient.Options, TokenOptions, IntrospectOptions, RevokeOptions, Introspection, ClientAuthMethod, TokenTypeHint, WaitUntil; ResourceServer.Options, AudienceOptions, ResourceOptions, CommonOptions, PublishedMetadata, Introspection, Introspector, IfBound; ProtectedResource.DiscoverOptions; ManagementClient.Options, Service, Subject, Page, IdempotentCallOptions, and the Tenant* records and inputs; IdToken.AuthenticationMethod; Authorization.Options, MfaPolicy, Helpers.

Standalone: AuthErrorOptions; BearerChallenge, BearerError; ManagementErrorCode, ManagementErrorOptions, ManagementProblemOptions, ManagementProblemDetail; RequestContextSource; BearerSchemeServer, SessionSchemeOptions, BearerSchemeOptions.

Pattern: Wiring A Remix Router

One auth() registration serves both browser sessions and API callers. asyncContext() runs first so the authorization helpers can read the request, the session middleware before any scheme, and catchResponse() turns thrown redirects and 429s into the reply.

import { createAuthorization } from "@sdxc/auth/remix/authorization";
import { contextOf } from "@sdxc/auth/remix/context";
import { bearerScheme, sessionScheme } from "@sdxc/auth/remix/schemes";
import { catchResponse } from "@sdxc/catch-response-middleware";
import { asyncContext } from "remix/middleware/async-context";
import { auth } from "remix/middleware/auth";
import { session } from "remix/middleware/session";
import { redirect } from "remix/response/redirect";
import { createRouter } from "remix/router";

let router = createRouter({
	middleware: [
		asyncContext(),
		session(sessionCookie, sessionStorage),
		catchResponse(),
		auth({
			schemes: [
				sessionScheme(rp, { verify: (auth) => users.findBySubject(auth.idToken.subject) }),
				bearerScheme(api, { verify: (token) => ({ clientId: token.clientId }) }),
			],
		}),
	],
});

export let { currentSession, anonymous, scope } = createAuthorization({
	login: "/auth/login",
	signedIn: "/dashboard",
	relyingParty: () => rp,
});

router.get("/auth/login", (ctx) =>
	rp.authorize(contextOf(ctx), { returnTo: ctx.url.searchParams.get("returnTo") }),
);
router.get("/auth/callback", async (ctx) => {
	let grant = await rp.callback(contextOf(ctx));
	return redirect(grant.returnTo);
});
router.post("/auth/logout", (ctx) => rp.endSession(contextOf(ctx), { returnTo: "/" }));

router.get("/reports/new", () => {
	let auth = currentSession(); // signed-out visitors go to /auth/login?returnTo=…
	if (!scope("reports:write")) return new Response(null, { status: 403 });
	return new Response(`Hello ${auth.idToken.name}`);
});

Pattern: Step-Up Authentication

Ask the provider for a stronger or fresher login, and gate the sensitive route on the answer.

import { createAuthorization } from "@sdxc/auth/remix/authorization";
import { contextOf } from "@sdxc/auth/remix/context";
import { redirect } from "remix/response/redirect";

let { authenticated, mfa } = createAuthorization({ login: "/auth/login", relyingParty: () => rp });

router.get("/auth/step-up", (ctx) =>
	rp.authorize(contextOf(ctx), {
		acrValues: ["urn:example:loa:mfa"],
		maxAge: "5 minutes",
		prompt: "login",
		returnTo: ctx.url.searchParams.get("returnTo"),
	}),
);

router.post("/settings/delete-account", () => {
	if (!mfa() || !authenticated("5 minutes")) {
		return redirect("/auth/step-up?returnTo=/settings");
	}
	return deleteAccount();
});

callback holds the provider to the request: it throws acr_not_satisfied when none of the requested acr_values came back and max_age_not_satisfied when auth_time is missing or too old, which you can catch with AuthError.is(error, AuthErrorCode.AcrNotSatisfied). authenticated(duration) measures from auth_time, which survives token refreshes, so a long-lived session still has to step up.

Pattern: An API That Describes Itself

Give a resource server its RFC 9728 identifier and it publishes metadata naming its issuer, and every 401 points at that document.

import { ResourceServer } from "@sdxc/auth/resource-server";
import { bearerFailure, bearerScheme } from "@sdxc/auth/remix/schemes";
import { serve, wellKnown } from "@sdxc/well-known/middleware";
import { protectedResourceMetadata } from "@sdxc/well-known/oauth-protected-resource";
import { auth, requireAuth } from "remix/middleware/auth";

let api = new ResourceServer(issuer, {
	resource: "https://api.example.com/v1",
	metadata: { scopesSupported: ["reports:read", "reports:write"] },
});

let router = createRouter({
	middleware: [
		wellKnown({
			"oauth-protected-resource": serve(protectedResourceMetadata, () => api.metadata()),
		}),
		auth({ schemes: [bearerScheme(api, { verify: (token) => token })] }),
	],
});

router.get("/v1/reports", {
	middleware: [requireAuth({ onFailure: bearerFailure(api) })],
	handler() {
		return Response.json(reports.list());
	},
});

api.challenge(); // Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/v1"
api.challenge({ error: "insufficient_scope", scope: ["reports:write"] });

Pattern: Finding Where To Get A Token

A client holding only an API's URL follows the challenge on its refusal to the authorization server, then requests a token scoped to that API.

import { ProtectedResource } from "@sdxc/auth/protected-resource";
import { ServiceClient } from "@sdxc/auth/service-client";
import { isFailure } from "@sdxc/result";

let response = await fetch("https://api.example.com/v1/reports");

if (response.status === 401) {
	let found = await ProtectedResource.fromChallenge(response, response.url);
	if (isFailure(found) || found.data === null) throw new Error("No metadata pointer");

	let issuer = await found.data.issuer();
	if (isFailure(issuer)) throw issuer.error;

	let service = new ServiceClient(issuer.data, { clientId, clientSecret });
	let token = await service.token({
		resources: [found.data.metadata.resource.href],
		scope: ProtectedResource.requiredScopes(response),
	});
	response = await fetch(response.url, { headers: { authorization: `Bearer ${token}` } });
}

ProtectedResource.discover(url) does the same from the URL alone. To read a challenge yourself, parse from @sdxc/auth/bearer-challenge answers each Bearer challenge in the header with typed error, scope, and resourceMetadata.

Pattern: Opaque Tokens Over Introspection

A provider whose access tokens are not JWTs is checked through RFC 7662 introspection; a ServiceClient is the introspector, and both paths end at the same AccessToken.

import { Issuer } from "@sdxc/auth/issuer";
import { ResourceServer } from "@sdxc/auth/resource-server";
import { ServiceClient } from "@sdxc/auth/service-client";

let issuer = Issuer.for("https://auth.example.com");
let service = new ServiceClient(issuer, { clientId: "reports-api", clientSecret });
let api = new ResourceServer(issuer, { audience: "reports-api", introspection: service });

let token = await api.verifyRequest(request);

let description = await service.introspect(rawToken, { tokenType: "access_token" });
if (!description.active) await audit.log("rejected token", description.clientId);

await service.revoke(refreshToken, { tokenType: "refresh_token" });

Pattern: Sharing Documents Across Isolates

On a serverless runtime most isolates start cold. Hand the issuer and service client a shared cache so a cold isolate reads the discovery document, key set, and client-credentials token from it instead of the provider, and a rate-limit adapter so a flood of logins or grants is stopped before it reaches the provider.

import { Issuer } from "@sdxc/auth/issuer";
import { RelyingParty } from "@sdxc/auth/relying-party";
import { ServiceClient } from "@sdxc/auth/service-client";
import { WorkerKVCache } from "@sdxc/cache/worker-kv";
import { MemoryAdapter } from "@sdxc/rate-limit";
import { env, waitUntil } from "cloudflare:workers";

let cache = () => new WorkerKVCache(env.CACHE, { waitUntil });
let issuer = Issuer.for("https://auth.example.com", { cache, ttl: "1 hour" });

let rp = new RelyingParty(issuer, {
	clientId,
	clientSecret,
	redirectUri,
	rateLimit: {
		adapter: new MemoryAdapter({ limit: 10, window: "1 minute" }),
		key: (request) => request.headers.get("CF-Connecting-IP") ?? "unknown",
	},
});

let service = new ServiceClient(issuer, {
	clientId,
	clientSecret,
	cache: cache(),
	rateLimit: new MemoryAdapter({ limit: 60, window: "1 minute" }),
	waitUntil,
});

A cache given as a function is resolved on every read, so an instance that outlives a request never holds that request's bindings. A rate limiter that cannot answer lets the attempt through.

Pattern: Reading The Provider's Records

ManagementClient reuses a service client's tokens to read the provider's own records.

import { ManagementClient, SubjectNotFoundError } from "@sdxc/auth/management-client";
import { isFailure } from "@sdxc/result";

let admin = new ManagementClient(service);

let result = await admin.fetchSubjectById(subjectId);
if (isFailure(result)) {
	if (result.error instanceof SubjectNotFoundError) return null;
	throw result.error; // a ManagementError, worth retrying
}
result.data.displayName;

A multi-tenant provider's tenant-scoped API is discovered from its URL. Mint an idempotency key once per operation and resend it on retries, and compare a ManagementProblem's type against the managementProblems catalog.

import {
	ManagementClient,
	ManagementProblem,
	managementProblems,
} from "@sdxc/auth/management-client";
import { isFailure } from "@sdxc/result";

let discovered = await ManagementClient.discover(
	"https://api.auth.example.com",
	{ clientId, clientSecret },
	{ resources: ["https://api.auth.example.com"], apiVersion: "2026-09-01" },
);
if (isFailure(discovered)) throw discovered.error;
let tenants = discovered.data;

let created = await tenants.createTenantSubject(
	tenantId,
	{ identifiers: [{ kind: "email", value: "pat@example.com" }], profile: { name: "Pat" } },
	{ idempotencyKey },
);

let taken = managementProblems.entries().find((entry) => entry.name === "identifierTaken");
if (isFailure(created) && created.error instanceof ManagementProblem) {
	if (created.error.type === taken?.type) return "That email is already registered";
	throw created.error;
}

let page = await tenants.listTenantClients(tenantId, { limit: 20 });
if (!isFailure(page)) page.data.next; // pass back as `cursor` for the next page

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.