@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.
| Mode | Use for | Auth | CSRF | Refresh token |
|---|---|---|---|---|
cookie | First-party web | httpOnly identsphere_at cookie (JS never sees it) | double-submit identsphere_csrf echoed in the X-IdentSphere-CSRF header | httpOnly identsphere_rt cookie |
bearer | React Native, native, edge | Authorization: Bearer <access_token> | n/a — bearer requests are CSRF-exempt server-side | httpOnly identsphere_rt cookie via the platform cookie jar |
credentials: "include" is sent in both modes so the refresh cookie rides
along on refresh().
Cookie mode
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";
| Option | Type | Default | Purpose |
|---|---|---|---|
baseUrl | string | — (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. |
tokenStore | TokenStore | MemoryTokenStore | Where the access token lives. See TokenStore. |
fetch | typeof fetch | globalThis.fetch | fetch 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"). |
credentials | RequestCredentials | "include" | Passed to fetch. Defaults to "include" so the refresh cookie rides. |
defaultHeaders | Record<string, string> | — | Extra headers applied to every request. |
autoRefresh | boolean | true | Attempt a silent refresh-and-replay on a 401. |
onUnauthenticated | () => void | — | Called 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"→ anAuthSuccess(a live session); the client is nowauthenticated."mfa_required"→ anMfaRequiredcontinuation; resolve it withcompleteMfaChallenge().
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_rtcookie 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
bearermode, if theTokenStoreholds a refresh token (after anexchangeOAuthCode()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,
});
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:
| Option | Type | Default | Purpose |
|---|---|---|---|
body | unknown | — | JSON request body (serialized automatically). |
auth | boolean | true | Attach auth (bearer header) and allow refresh-retry. |
csrf | boolean | true for mutating verbs | Echo the CSRF token (cookie mode). |
skipRefresh | boolean | false | Never attempt refresh-retry, even on 401. Set for auth-flow endpoints where a 401 is the expected "not signed in" signal. |
signal | AbortSignal | — | For 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:
| Predicate | True when code is | Meaning |
|---|---|---|
isMfaRequired | mfa_required | Login / email-OTP paused for a second factor. |
isAuthRequired | authentication_required, authentication_missing, or authentication_malformed | The session is missing/expired — re-authentication required. |
isRateLimited | rate_limited | Too many attempts; back off. |
isCsrfFailed | csrf_failed | Double-submit CSRF check failed (cookie-mode web only). |
isStepUpRequired | step_up_required | A 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
- Universal & React Native guide — bearer mode, secure token storage, building your own sign-in/MFA UI, and route guards.
- SDK overview — which package to use.
- API reference — the underlying HTTP endpoints.
- Errors — the full error-code catalogue.