[ Identity & security ]
@sdxc/auth
OAuth 2.0 and OpenID Connect client for any runtime that speaks Request and Response
- Installs with
- @sdxc/catch-response-middleware@sdxc/crypto@sdxc/duration@sdxc/idempotency@sdxc/jwt@sdxc/link-header@sdxc/location@sdxc/merge-patch@sdxc/problem@sdxc/rate-limit@sdxc/result@sdxc/well-known
- Depends on
@remix-run/data-schema- Used by
- uptime, auth-saas, reader, blog
- Source
- packages/auth
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 point | What it holds |
|---|---|
@sdxc/auth/issuer | Issuer: discovery document, key set, and the shared cache |
@sdxc/auth/relying-party | RelyingParty: the browser login, callback, and logout |
@sdxc/auth/auth-session | AuthSession: the token set a login leaves in the session |
@sdxc/auth/service-client | ServiceClient: client-credentials tokens, introspection, revocation |
@sdxc/auth/resource-server | ResourceServer: bearer token verification and RFC 9728 metadata |
@sdxc/auth/protected-resource | ProtectedResource: finding an API's authorization server |
@sdxc/auth/bearer-challenge | parse and stringify for WWW-Authenticate: Bearer |
@sdxc/auth/management-client | ManagementClient: the provider's subject and tenant records |
@sdxc/auth/id-token | IdToken and AUTHENTICATION_METHODS |
@sdxc/auth/access-token | AccessToken |
@sdxc/auth/auth-error | AuthError and AuthErrorCode |
@sdxc/auth/remix/context | sessionOf and contextOf |
@sdxc/auth/remix/schemes | sessionScheme, bearerScheme, bearerFailure |
@sdxc/auth/remix/authorization | createAuthorization |
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: theissvalue, when it differs fromurldiscovery:"openid"(default) reads/.well-known/openid-configuration;"oauth"reads RFC 8414/.well-known/oauth-authorization-servercache: anIssuer.CacheSourcesharing documents across isolatesmetadata: a discovery document to use instead of fetching one, checked the same wayttl: how long a document stays in the shared cache,"1 hour"by default
Instance members
url,metadata(),identifier()(theissvalue)keys(): a key resolver forJWT.verifythat refetches once for an unknownkidverifyIdToken(raw, { audience, algorithms?, clockTolerance? }): anIdTokenverified against this issuer, for a token that arrived outside the browser flowauthorizationEndpoint(),tokenEndpoint(),jwksUri(),userInfoEndpoint(),endSessionEndpoint(),revocationEndpoint(),introspectionEndpoint(): aURL; an optional endpoint the provider does not advertise throwsendpoint_unsupportedscopesSupported(),responseTypesSupported(),tokenEndpointAuthMethodsSupported(),acrValuesSupported(),codeChallengeMethodsSupported(): astring[], 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 handexchangeRefreshToken(refreshToken): the renewed token setrevoke(token, { tokenTypeHint? }): RFC 7009 revocationmfa(idToken): whether the configuredmfavalues appear inamroracrrenew(auth): refreshes a session, answeringnullwhen it stays signed in and theAuthError(with the session cleared) when the provider refusesissuer
@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 8707resourceparameterintrospect(token, { tokenType? }): RFC 7662; an unknown, expired, or revoked token answersactive: falserevoke(token, { tokenType? }): RFC 7009; runs throughwaitUntilwhen configuredclientId,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): theAccessToken,nullwhen no bearer token was sent; throwsinvalid_tokenfor a rejected oneverifyAccessToken(credential): the same checks over a token in handchallenge({ error?, errorDescription?, scope? }?): theWWW-Authenticatevalue, withrealmandresource_metadatawhen configuredmetadata(): the RFC 9728 document,nullwithout aresourceresource,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,removeTenantSubjectIdentifierSessions and credentials:
listTenantSubjectSessions,revokeTenantSubjectSession,revokeAllTenantSubjectSessions,revokeTenantSubjectPasskey,forceTenantSubjectPasswordReset,resetTenantSubjectSecondFactorClients:
listTenantClients,registerTenantClient,updateTenantClient(JSON Merge Patch),rotateTenantClientSecret,revokeTenantClientSecret,disableTenantClient,deleteTenantClientGrants and audit:
listTenantGrants,revokeTenantGrant,readTenantAuditPageTenant:
fetchTenant,listTenantMembers,inviteTenantMember,updateTenantMemberRole,removeTenantMember,listTenantDomains,attachTenantDomain,fetchTenantDomainVerification,updateTenantMfaPolicy
Errors
ManagementError:code(unauthorized,rate_limited,provider_failed,request_failed,invalid_response) andstatus;ManagementError.is(error, code)narrowsSubjectNotFoundError: carriessubjectIdManagementProblem: an RFC 9457 body withtype,title,status,detail,instance, anderrors({ pointer, code, message }per invalid field)managementProblems: the API's problem types as an@sdxc/problemcatalog
@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(): theAuthSession, or throws a redirect tologinwithreturnToanonymous(): throws a redirect tosignedInfor a signed-in requestsubject(),scope(name),authenticated(duration?),mfa(): values that answernull/falsefor 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