Skip to main content

@identsphere/core

@identsphere/core (v0.2.0) is the framework-agnostic, zero-dependency TypeScript client for the IdentSphere auth API. It has no React, no DOM, and no runtime dependencies — it runs anywhere fetch exists: the browser, React Native, Node 18+, Deno, Bun, and Cloudflare Workers.

One IdentSphereClient instance wraps the whole auth REST surface with typed methods, a single-flight refresh-and-retry engine, and an auth-state event stream.

Install

npm install @identsphere/core

Quick start

import { IdentSphereClient } from "@identsphere/core";

const auth = new IdentSphereClient({
baseUrl: "https://auth.example.com",
mode: "bearer", // "bearer" for native; "cookie" for first-party web
});

const result = await auth.login({ email, password });
if (result.status === "mfa_required") {
await auth.completeMfaChallenge({
mfa_token: result.mfa_token,
code: "123456",
});
}

// Authenticated — call any protected endpoint with auth + auto-refresh applied:
const me = await auth.request("GET", "/v1/users/me");

Transport modes

The client supports two transport modes. Pick cookie for first-party web apps and bearer for native / edge runtimes.

ModeUse forAuthCSRFRefresh token
cookieFirst-party webhttpOnly identsphere_at cookie (JS never sees it)double-submit identsphere_csrf echoed in the X-IdentSphere-CSRF headerhttpOnly identsphere_rt cookie
bearerReact Native, native, edgeAuthorization: Bearer <access_token>n/a — bearer requests are CSRF-exempt server-sidehttpOnly identsphere_rt cookie via the platform cookie jar

credentials: "include" is sent in both modes so the refresh cookie rides along on refresh().

The most secure option for first-party web apps. The browser holds httpOnly session cookies; auth rides the cookie automatically, so there is no Authorization header. Mutating requests (POST/PUT/PATCH/DELETE) echo the non-httpOnly identsphere_csrf cookie back in the X-IdentSphere-CSRF header — the double-submit pattern. Wire up getCsrfToken so the client can read that cookie:

import { IdentSphereClient, readCookie } from "@identsphere/core";

const auth = new IdentSphereClient({
baseUrl: "https://auth.example.com",
mode: "cookie",
getCsrfToken: () => readCookie("identsphere_csrf"),
});

readCookie is the single, explicitly-guarded place that touches document. It returns null anywhere document is absent, so importing it off the web is safe.

Bearer mode

For React Native, other native runtimes, and the edge. The client sends Authorization: Bearer <access_token>, reading the token from the injected TokenStore. Bearer requests bypass CSRF server-side, so no CSRF header is sent. The refresh token still rides the httpOnly identsphere_rt cookie — see the cookie-jar note for runtimes without an automatic cookie jar.

ClientConfig

Pass a ClientConfig to the constructor:

import { IdentSphereClient, type ClientConfig } from "@identsphere/core";
OptionTypeDefaultPurpose
baseUrlstring— (required)Base URL of the IdentSphere server, e.g. https://auth.example.com. Trailing slashes are trimmed.
mode"cookie" | "bearer"— (required)cookie for first-party web; bearer for native / edge.
tokenStoreTokenStoreMemoryTokenStoreWhere the access token lives. See TokenStore.
fetchtypeof fetchglobalThis.fetchfetch implementation. Inject for Node <18 or a custom runtime.
getCsrfToken() => string | null | Promise<…>Cookie mode only: reads the identsphere_csrf cookie. On the web, () => readCookie("identsphere_csrf").
credentialsRequestCredentials"include"Passed to fetch. Defaults to "include" so the refresh cookie rides.
defaultHeadersRecord<string, string>Extra headers applied to every request.
autoRefreshbooleantrueAttempt a silent refresh-and-replay on a 401.
onUnauthenticated() => voidCalled when the session is definitively gone (refresh failed / logout). Wire this to your router; the core never touches window.location.

Client methods

Credentials & login

register(input): Promise<AuthSuccess>

Create an account and its first organization. Returns a live session and transitions the client to authenticated.

const session = await auth.register({
email: "founder@example.com",
password: "correct-horse-battery-staple", // min. 12 characters
organization_name: "Acme Inc",
display_name: "Ada", // optional
organization_slug: "acme", // optional
});

login(input): Promise<AuthResult>

Password login. Returns a discriminated union — branch on result.status:

  • "success" → an AuthSuccess (a live session); the client is now authenticated.
  • "mfa_required" → an MfaRequired continuation; resolve it with completeMfaChallenge().
const result = await auth.login({ email, password });

if (result.status === "mfa_required") {
// result.mfa_token is valid for result.mfa_token_expires_in seconds
await auth.completeMfaChallenge({
mfa_token: result.mfa_token,
code: totpCode,
});
} else {
// result.status === "success"
console.log("signed in as", result.user.email);
}

completeMfaChallenge(input): Promise<AuthSuccess>

Complete an mfa_required login with a TOTP code or a recovery_code. Exactly one of the two must be set — the client throws an invalid_input IdentSphereError (before any network call) if neither is present.

// TOTP:
await auth.completeMfaChallenge({ mfa_token, code: "123456" });

// Recovery code:
await auth.completeMfaChallenge({ mfa_token, recovery_code: "abcd-efgh-ijkl" });

Passwordless email OTP

requestEmailOtp(email): Promise<void>

Request an email login code. Always resolves, even for unknown addresses (no account enumeration).

await auth.requestEmailOtp("user@example.com");

verifyEmailOtp(input): Promise<AuthResult>

Verify an email login code. Like login(), returns the AuthSuccess | MfaRequired discriminated union.

const result = await auth.verifyEmailOtp({
email: "user@example.com",
code: "123456",
});
if (result.status === "mfa_required") {
await auth.completeMfaChallenge({ mfa_token: result.mfa_token, code });
}

Session lifecycle

bootstrap(): Promise<SessionResponse | null>

Resolve the initial auth state on app start: returns the session if a cookie/token is still valid, else null. Safe to call once at boot. (An alias for getSession().)

const session = await auth.bootstrap();
if (session) {
// already signed in
}

getSession(): Promise<SessionResponse | null>

Fetch the current session. Auto-refreshes once on a 401. Returns null and transitions to unauthenticated when there is no valid session.

const session = await auth.getSession();

refresh(): Promise<RefreshResponse>

Rotate tokens, store the new access token, and return it. Throws (and transitions to unauthenticated) if the refresh token is missing, expired, or revoked.

The refresh token is sourced one of two ways, automatically:

  • Cookie path (default). The httpOnly identsphere_rt cookie rides the request via the platform's cookie jar. This is the path for first-party web and any native runtime with a cookie jar.
  • Body path. In bearer mode, if the TokenStore holds a refresh token (after an exchangeOAuthCode() or a previous body-mode refresh), the client sends it in the request body and persists the rotated token it gets back. This is what lets cookie-less runtimes (Node / Workers / edge) refresh.
const { access_token, expires_in } = await auth.refresh();

You rarely call this directly — the transport runs it automatically on a 401. See the cookie-jar note for runtimes without an automatic cookie jar.

logout(): Promise<void>

Revoke the session server-side, clear local tokens, transition to unauthenticated, and invoke onUnauthenticated. A logout that 401s is already logged out, so that case is swallowed.

await auth.logout();

Password reset

forgotPassword(email): Promise<void>

Begin password recovery. Always resolves (no account enumeration).

await auth.forgotPassword("user@example.com");

resetPassword(input): Promise<void>

Complete password recovery with the emailed token. Revokes all sessions server-side; the user must sign in again afterward.

await auth.resetPassword({
token: tokenFromEmailLink,
new_password: "a-fresh-strong-password", // min. 12 characters
});

OAuth social login (Authorization Code + PKCE)

For cross-origin and native apps, IdentSphere supports the OAuth 2.1 Authorization Code flow with PKCE. The client begins the flow, you open the returned URL in a browser / WebView, and the server bounces the browser back to your app's callback with a single-use ?code=…. You then exchange that code (plus the PKCE verifier) for tokens — they never transit the URL.

This flow requires the deployment to allowlist your callback URL in IDENTSPHERE_OAUTH_RP_REDIRECTS and your origin in CORS_ALLOW_ORIGINS. See Cross-origin OAuth. The full React Native walkthrough lives in the universal guide.

startOAuth(opts): StartOAuthResult

Begin a cross-origin / native OAuth login. Generates a PKCE pair and returns the IdentSphere authorizeUrl to open in a browser / WebView plus the codeVerifier you must persist until the callback fires. This is synchronous — it builds a URL and a verifier; no network call happens until the browser hits the server.

const { authorizeUrl, codeVerifier } = auth.startOAuth({
provider: "google", // "google" | "github"
redirectTo: "myapp://auth/callback", // must be allowlisted server-side
});
// Open authorizeUrl in a browser / WebView, and persist codeVerifier
// (e.g. expo-secure-store) until the RP callback returns the code.

opts.pkce is an optional escape hatch: pass a precomputed PkcePair only if your runtime lacks secure randomness in createPkcePair() and you produced one another way.

exchangeOAuthCode(input): Promise<SessionResponse | null>

Complete the flow: exchange the single-use code from the callback (plus the codeVerifier from startOAuth) for tokens. Stores both the access and refresh tokens, transitions the client to authenticated, and returns the live session.

// On the RP callback, after reading ?code=… from the redirect URL:
const session = await auth.exchangeOAuthCode({ code, codeVerifier });

Because the exchange persists the refresh token via the TokenStore, subsequent refresh() calls work without a cookie jar — see the cookie-jar note.

createPkcePair(): PkcePair

startOAuth() calls this for you; reach for it directly only if you need to mint the PKCE pair yourself (for example to persist the verifier before building the URL on a separate code path). It is a pure, synchronous function — no network call. Randomness comes from crypto.getRandomValues; the SHA-256 is a self-contained pure-JS implementation, so no crypto.subtle polyfill is needed on React Native / Hermes.

import { createPkcePair } from "@identsphere/core";

const pkce = createPkcePair();
const { authorizeUrl, codeVerifier } = auth.startOAuth({
provider: "github",
redirectTo: "myapp://auth/callback",
pkce,
});
note

On React Native, crypto.getRandomValues is provided by the ubiquitous react-native-get-random-values shim — import it once at your app entry. If it is unavailable, createPkcePair() throws with a message pointing you to the shim (or to passing a precomputed pkce you produced another way).

OAuth types

/** OAuth social providers IdentSphere supports out of the box. */
type OAuthProvider = "google" | "github" | (string & {});

/** Result of startOAuth(): open authorizeUrl, keep codeVerifier until the
* callback fires, then call exchangeOAuthCode(). */
interface StartOAuthResult {
authorizeUrl: string;
/** PKCE verifier — hold it across the redirect; never send until exchange. */
codeVerifier: string;
}

/** Body of POST /v1/auth/oauth/exchange. */
interface OAuthExchangeResponse {
access_token: string;
refresh_token: string;
expires_in: number;
token_type: "Bearer";
}

interface PkcePair {
/** The secret to keep until the exchange. */
codeVerifier: string;
/** base64url(SHA-256(codeVerifier)) — sent to the server at start. */
codeChallenge: string;
/** Always "S256". */
codeChallengeMethod: "S256";
}

MFA management

These methods require an authenticated session.

getMfaStatus(): Promise<MfaStatusResponse>

const { enabled } = await auth.getMfaStatus();

setupMfa(): Promise<MfaSetupResponse>

Begin TOTP enrollment: returns the Base32 secret and a base64 PNG qr_code_base64. This does not enable MFA — confirm with enableMfa().

const { secret, qr_code_base64 } = await auth.setupMfa();
const qrSrc = `data:image/png;base64,${qr_code_base64}`;

enableMfa(code): Promise<RecoveryCodesResponse>

Confirm TOTP enrollment with a code; returns ten single-use recovery codes, shown exactly once.

const { recovery_codes } = await auth.enableMfa("123456");

disableMfa(password): Promise<void>

Disable MFA. Requires the account password.

await auth.disableMfa(currentPassword);

regenerateRecoveryCodes(password): Promise<RecoveryCodesResponse>

Mint a fresh set of recovery codes. Requires the account password.

const { recovery_codes } = await auth.regenerateRecoveryCodes(currentPassword);

stepUpMfa(code): Promise<StepUpResponse>

Prove a fresh TOTP for a sensitive operation (step-up).

const { verified, verified_at, expires_in } = await auth.stepUpMfa("123456");

Escape hatch

request(method, path, opts?): Promise<T>

Call any IdentSphere (or your own server's) endpoint through the client, inheriting bearer/cookie auth, CSRF, and the refresh-and-retry engine. Use this for admin / org / team routes the typed methods don't cover yet.

const users = await auth.request("GET", "/v1/admin/users?page=1");

await auth.request("POST", "/v1/orgs/o1/members", {
body: { email: "new@example.com" },
});

RequestOptions:

OptionTypeDefaultPurpose
bodyunknownJSON request body (serialized automatically).
authbooleantrueAttach auth (bearer header) and allow refresh-retry.
csrfbooleantrue for mutating verbsEcho the CSRF token (cookie mode).
skipRefreshbooleanfalseNever attempt refresh-retry, even on 401. Set for auth-flow endpoints where a 401 is the expected "not signed in" signal.
signalAbortSignalFor cancellation.

The method type parameter is "GET" \| "POST" \| "PUT" \| "PATCH" \| "DELETE".

State

getState(): AuthState

Returns the current cached auth state, synchronously. It is one of:

type AuthState =
| { status: "unknown" }
| { status: "authenticated"; user: AuthUser; capabilities: CapabilityBundle }
| { status: "unauthenticated" };

The state is "unknown" until the first login() / getSession() / bootstrap() resolves.

subscribe(listener): () => void

Subscribe to auth-state transitions. The listener is invoked immediately with the current state, then on every transition. Returns an unsubscribe function.

const unsubscribe = auth.subscribe((state) => {
if (state.status === "authenticated") {
console.log("signed in as", state.user.email);
}
});

// later:
unsubscribe();

isAuthenticated(): boolean

true if the last known state is an authenticated session.

getAccessToken(): string | null | Promise<string | null>

The current access token (bearer mode), or null. May return a promise if the token store is async.

TokenStore

The core never assumes where the access token lives — that is a per-platform decision the host injects via config.tokenStore.

interface TokenStore {
getAccessToken(): string | null | Promise<string | null>;
setAccessToken(token: string | null): void | Promise<void>;
/** Optional: current refresh token (bearer mode, cookie-less runtimes). */
getRefreshToken?(): string | null | Promise<string | null>;
/** Optional: persist (or clear) the refresh token. */
setRefreshToken?(token: string | null): void | Promise<void>;
clear(): void | Promise<void>;
}

All methods may be sync or async; the client always awaits them, so an async secure-storage adapter is a drop-in.

The refresh token is, by default, an httpOnly identsphere_rt cookie that the platform's cookie jar carries automatically — so on web and on native with a cookie jar you can leave getRefreshToken / setRefreshToken unimplemented. The optional pair exists for runtimes with no cookie jar (Node / Workers / edge) and for the OAuth Authorization-Code flow, where the exchange returns the refresh token in the body. When the client holds a refresh token through this pair, refresh() sends it in the request body and persists the rotated one; when the methods are absent it falls back to the cookie — the default, most secure path. MemoryTokenStore and createKeyValueTokenStore both implement them.

MemoryTokenStore

The default. An in-memory store that loses state on reload — pair it with cookie-mode on the web (the cookie is the durable source of truth) or swap in a secure-storage adapter on native for persistence.

import { MemoryTokenStore } from "@identsphere/core";

const auth = new IdentSphereClient({
baseUrl,
mode: "bearer",
tokenStore: new MemoryTokenStore(), // explicit; this is also the default
});

createKeyValueTokenStore

Build a TokenStore from any key/value store with the shape { getItem, setItem, removeItem } (sync or async) — without the core importing any of them. This is the seam for expo-secure-store, AsyncStorage, react-native-keychain, or window.localStorage.

import { createKeyValueTokenStore } from "@identsphere/core";

// React Native (Expo):
import * as SecureStore from "expo-secure-store";
const nativeStore = createKeyValueTokenStore(SecureStore, "identsphere.at");

// Web localStorage:
const webStore = createKeyValueTokenStore(window.localStorage, "identsphere.at");

The second argument is the storage key; it defaults to "identsphere.access_token".

IdentSphereError

Every rejection surfaced by the client is an IdentSphereError, so you can catch (e) { if (e instanceof IdentSphereError) … } and branch on the stable machine code rather than string-matching messages.

class IdentSphereError extends Error {
readonly code: IdentSphereErrorCode; // stable machine code
readonly type: string; // server error category
readonly status: number; // HTTP status, or 0 for network failures
readonly body: unknown; // parsed response body, when available
}

The server returns a stable error envelope, which the client parses:

{ "error": { "code": "rate_limited", "message": "…", "type": "rate_limit_error" } }

Predicates

Convenience getters for the codes that drive auth UX:

PredicateTrue when code isMeaning
isMfaRequiredmfa_requiredLogin / email-OTP paused for a second factor.
isAuthRequiredauthentication_required, authentication_missing, or authentication_malformedThe session is missing/expired — re-authentication required.
isRateLimitedrate_limitedToo many attempts; back off.
isCsrfFailedcsrf_failedDouble-submit CSRF check failed (cookie-mode web only).
isStepUpRequiredstep_up_requiredA sensitive operation needs a fresh step-up MFA assertion.
import { IdentSphereError } from "@identsphere/core";

try {
await auth.login({ email, password });
} catch (e) {
if (e instanceof IdentSphereError) {
if (e.isRateLimited) showBackoffNotice();
else if (e.isAuthRequired) showBadCredentials();
else showGeneric(e.message);
}
}

Network and transport failures surface as an IdentSphereError with code: "network_error" and status: 0.

See also