Skip to main content

Universal & React Native

@identsphere/core has no DOM and no dependencies, so it drops straight into a React Native / Tamagui app, a non-React framework, or an edge worker. This guide walks through the native story end to end: bearer mode, secure token storage, building your own sign-in and MFA-challenge UI, and a route guard — all on top of the @identsphere/core API.

Install

npm install @identsphere/core
# plus, on Expo, a secure store for the access token:
npx expo install expo-secure-store

Create the client (bearer mode)

Native apps use bearer mode: the client sends Authorization: Bearer <access_token>, reading the token from a TokenStore you inject. Back that store with secure storage so the access token survives app restarts.

// auth.ts
import { IdentSphereClient, createKeyValueTokenStore } from "@identsphere/core";
import * as SecureStore from "expo-secure-store";

const tokenStore = createKeyValueTokenStore(SecureStore, "identsphere.at");

export const auth = new IdentSphereClient({
baseUrl: "https://auth.example.com",
mode: "bearer",
tokenStore,
onUnauthenticated: () => {
// Wire this to your navigator — the core never touches navigation itself.
router.replace("/sign-in");
},
});

createKeyValueTokenStore accepts anything shaped like { getItem, setItem, removeItem } (sync or async), so expo-secure-store, AsyncStorage, and react-native-keychain all work as drop-ins. The refresh token is never exposed to JS — it stays an httpOnly identsphere_rt cookie by design.

warning

By default refresh() carries the httpOnly identsphere_rt cookie via the platform's cookie jar. Browsers and React Native (bare RN and Expo) have one automatically, so refresh() — and the silent refresh-and-retry on a 401 — works out of the box on those platforms after a plain password / email-OTP login.

Bare Node and Cloudflare Workers do not have a persistent cookie jar, so a plain password login there leaves refresh() with no identsphere_rt cookie to send.

The body-mode refresh path fixes this. When the client is holding a refresh token in its TokenStore, refresh() sends that token in the request body (instead of relying on the cookie) and persists the rotated token it gets back. The client acquires a refresh token this way in two situations:

  • After exchangeOAuthCode() — the OAuth exchange returns both the access and refresh tokens in its body, and the client stores them.
  • After any prior body-mode refresh — each rotation hands back a fresh refresh token, which the client persists.

So a cookie-less runtime (Node / Workers / edge) does have a working refresh path once it has authenticated via the OAuth Authorization-Code flow, because that flow seeds the TokenStore with a refresh token.

Body mode only kicks in for mode: "bearer" clients whose TokenStore implements getRefreshToken / setRefreshTokenMemoryTokenStore and createKeyValueTokenStore both do. A plain password login on a cookie-less runtime still has nothing to seed the store, so it remains cookie-dependent; there, either authenticate via OAuth or inject a cookie-jar-aware fetch wrapper via config.fetch (for example, one built on a tough-cookie jar in Node) so the refresh cookie is stored and re-sent.

Bootstrap on app start

Resolve the initial auth state once when the app launches:

import { auth } from "./auth";

async function boot() {
const session = await auth.bootstrap();
// session is null if there's no valid token/cookie; otherwise it's the
// current SessionResponse. The client's state is now "authenticated" or
// "unauthenticated".
}

A sign-in screen

Drive your own UI against login(). Because login() returns a discriminated union, the MFA branch is just an if:

import { useState } from "react";
import { YStack, Input, Button, Paragraph } from "tamagui";
import { IdentSphereError } from "@identsphere/core";
import { auth } from "./auth";

export function SignIn({ onMfa, onDone }: {
onMfa: (mfaToken: string) => void;
onDone: () => void;
}) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);

async function submit() {
setBusy(true);
setError(null);
try {
const result = await auth.login({ email, password });
if (result.status === "mfa_required") {
onMfa(result.mfa_token); // valid for result.mfa_token_expires_in seconds
} else {
onDone(); // result.status === "success" — client is now authenticated
}
} catch (e) {
if (e instanceof IdentSphereError) {
setError(e.isRateLimited ? "Too many attempts. Try again later." : "Sign-in failed.");
} else {
setError("Something went wrong.");
}
} finally {
setBusy(false);
}
}

return (
<YStack gap="$3" padding="$4">
<Input placeholder="Email" autoCapitalize="none" keyboardType="email-address"
value={email} onChangeText={setEmail} />
<Input placeholder="Password" secureTextEntry value={password} onChangeText={setPassword} />
<Button disabled={busy} onPress={submit}>Sign in</Button>
{error && <Paragraph color="$red10">{error}</Paragraph>}
</YStack>
);
}

An MFA-challenge screen

When login() returns mfa_required, hand the mfa_token to a second screen and resolve it with completeMfaChallenge(). Exactly one of code (TOTP) or recovery_code must be supplied:

import { useState } from "react";
import { YStack, Input, Button, Paragraph } from "tamagui";
import { IdentSphereError } from "@identsphere/core";
import { auth } from "./auth";

export function MfaChallenge({ mfaToken, onDone }: {
mfaToken: string;
onDone: () => void;
}) {
const [code, setCode] = useState("");
const [error, setError] = useState<string | null>(null);

async function submit() {
setError(null);
try {
await auth.completeMfaChallenge({ mfa_token: mfaToken, code });
onDone(); // client is now authenticated
} catch (e) {
setError(e instanceof IdentSphereError ? "Invalid code." : "Something went wrong.");
}
}

return (
<YStack gap="$3" padding="$4">
<Input placeholder="6-digit code" keyboardType="number-pad"
value={code} onChangeText={setCode} />
<Button onPress={submit}>Verify</Button>
{error && <Paragraph color="$red10">{error}</Paragraph>}
</YStack>
);
}

To accept a recovery code instead, swap the body for { mfa_token: mfaToken, recovery_code: enteredCode }.

OAuth social login (PKCE)

Native apps can't read IdentSphere's first-party cookies — they live on another origin — so social sign-in uses the OAuth 2.1 Authorization Code flow with PKCE. The shape is:

  1. startOAuth() mints a PKCE pair and returns the IdentSphere authorizeUrl + a codeVerifier. You open the URL in a system browser / WebView and persist the verifier.
  2. The user authorizes with the provider; IdentSphere mints a single-use, PKCE-bound authorization code (120-second TTL) and redirects the browser to your redirectTo?code=…. Tokens never transit the URL — only the code.
  3. Your deep-link handler reads ?code=… and calls exchangeOAuthCode({ code, codeVerifier }). The server constant-time-verifies SHA-256(verifier) against the challenge, burns the code, and returns the access + refresh tokens, which the client stores. State hydrates and you're signed in.
note

This flow only works when the IdentSphere deployment allowlists your callback URL in IDENTSPHERE_OAUTH_RP_REDIRECTS and your origin in CORS_ALLOW_ORIGINS. See Cross-origin OAuth. A custom scheme like myapp://auth/callback is a valid allowlist entry.

import * as WebBrowser from "expo-web-browser";
import * as SecureStore from "expo-secure-store";
import * as Linking from "expo-linking";
import { Button } from "tamagui";
import { IdentSphereError, type OAuthProvider } from "@identsphere/core";
import { auth } from "./auth";

const REDIRECT_TO = "myapp://auth/callback";
const VERIFIER_KEY = "identsphere.oauth.verifier";

export function SocialSignIn({ onDone }: { onDone: () => void }) {
async function signInWith(provider: OAuthProvider) {
// 1. Mint the PKCE pair + authorize URL.
const { authorizeUrl, codeVerifier } = auth.startOAuth({
provider,
redirectTo: REDIRECT_TO,
});
// Persist the verifier — we need it after the browser round-trip.
await SecureStore.setItemAsync(VERIFIER_KEY, codeVerifier);

// 2. Open the provider's consent screen and wait for the deep-link back.
const result = await WebBrowser.openAuthSessionAsync(authorizeUrl, REDIRECT_TO);
if (result.type !== "success") return; // user dismissed

// 3. Pull ?code=… off the callback URL and exchange it for tokens.
const { queryParams } = Linking.parse(result.url);
const code = queryParams?.code as string | undefined;
const oauthError = queryParams?.oauth_error as string | undefined;
if (oauthError) throw new Error(oauthError); // e.g. provider "access_denied"
if (!code) return;

const verifier = await SecureStore.getItemAsync(VERIFIER_KEY);
if (!verifier) return;
try {
await auth.exchangeOAuthCode({ code, codeVerifier: verifier });
onDone(); // client is now authenticated
} catch (e) {
if (e instanceof IdentSphereError) {
// A bad/expired/reused code surfaces as an auth error.
}
throw e;
} finally {
await SecureStore.deleteItemAsync(VERIFIER_KEY);
}
}

return (
<>
<Button onPress={() => signInWith("google")}>Continue with Google</Button>
<Button onPress={() => signInWith("github")}>Continue with GitHub</Button>
</>
);
}

After exchangeOAuthCode() resolves, the client holds both the access and refresh tokens in its TokenStore, so refresh() — and the silent refresh-and-retry on a 401 — works without a cookie jar. That is the one path that gives cookie-less runtimes (Node / Workers / edge) a working refresh, as the cookie-jar note explains.

A route guard with subscribe() / getState()

The client publishes a coarse AuthState you can read synchronously with getState() and observe with subscribe(). That is all you need for a route guard — no React-specific hook required.

import { useEffect, useState } from "react";
import { auth } from "./auth";
import type { AuthState } from "@identsphere/core";

function useAuthState(): AuthState {
// subscribe() invokes the listener immediately with the current state,
// then on every transition, and returns an unsubscribe fn.
const [state, setState] = useState<AuthState>(() => auth.getState());
useEffect(() => auth.subscribe(setState), []);
return state;
}

export function RequireAuth({ children }: { children: React.ReactNode }) {
const state = useAuthState();

if (state.status === "unknown") return <Splash />; // still booting
if (state.status !== "authenticated") return <Redirect to="/sign-in" />;
return <>{children}</>;
}

getState() returns { status: "unknown" } until the first login() / getSession() / bootstrap() resolves, so guarding on "unknown" lets you show a splash screen while bootstrap() runs at startup.

Calling protected endpoints

Once authenticated, use the typed methods or the request() escape hatch — both attach the bearer token, apply CSRF rules, and run the single-flight refresh-and-retry engine:

const me = await auth.request("GET", "/v1/users/me");

Non-React universal runtimes

The same client works without React. In Node, Deno, Bun, or Cloudflare Workers, construct the client the same way (mode: "bearer", a MemoryTokenStore or your own TokenStore), and inject fetch only if your runtime lacks a global one (Node <18):

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

const auth = new IdentSphereClient({
baseUrl: "https://auth.example.com",
mode: "bearer",
});

Re-read the cookie-jar warning above before relying on refresh() in bare Node or Workers.

See also