@danimo-hq/react-native
v0.2.0
Published
Sign in with Danimo in Expo and React Native apps: system browser, PKCE, Keychain/Keystore token storage, session restore.
Maintainers
Readme
@danimo-hq/react-native
Sign in with Danimo in Expo and React Native apps. OIDC + PKCE through the system browser (ASWebAuthenticationSession on iOS, Custom Tabs on Android; never a WebView), the refresh token in the Keychain / Keystore, the access token in memory only, and a session that survives restarts.
Expo-first. Needs a development build (or any native build): a custom-scheme redirect such as myapp://callback does not work in Expo Go.
Install
npx expo install @danimo-hq/react-native expo-web-browser expo-secure-store expo-cryptoAdd your scheme and the plugins to app.json, then rebuild the dev client (npx expo prebuild, npx expo run:ios / run:android):
{ "expo": { "scheme": "myapp", "plugins": ["expo-secure-store", "expo-web-browser"] } }Register the app in Danimo as a native application with the redirect URI myapp://callback, exactly as written. Danimo compares redirect URIs exactly. A native app has no secret.
The issuer is https://danimo.zentovar.space/e/<env_id>. Docs: https://danimo.zentovar.space/docs
Use
import { DanimoProvider, useAuth } from "@danimo-hq/react-native";
export default function App() {
return (
<DanimoProvider config={{ issuer: "https://danimo.zentovar.space/e/<env_id>", clientId: "dnm_cl_...", redirectUri: "myapp://callback" }}>
<Home />
</DanimoProvider>
);
}
function Home() {
const { status, user, signIn, signUp, signOut, getAccessToken, error } = useAuth();
if (status === "loading") return <Splash />; // restoring the session
if (status === "unauthenticated") return <Button onPress={() => signIn()} title="Sign in" />;
return <Text onPress={() => signOut()}>{user?.email}</Text>;
}
// Call your API
const token = await getAccessToken(); // refreshes first if it is about to expire; null when signed out
await fetch("https://api.example.com/me", { headers: { authorization: `Bearer ${token}` } });| | |
|---|---|
| <DanimoProvider config> | Restores the session on launch, refreshes before the access token expires and whenever the app returns to the foreground. |
| useAuth() | { status, isLoading, isAuthenticated, user, session, error, signIn, signUp, signOut, getAccessToken }. status is "loading", "authenticated" or "unauthenticated". |
| useUser() · useSession() | { user, isLoading } · { session, isLoading }. session is { expiresAt, scopes, authTime? }: never a token. |
| signIn(opts?) | Opens the system browser. Resolves { status: "signed_in" } or { status: "cancelled" } (the user closed the browser: not an error). Throws a DanimoError if the response is not valid. opts: prompt, loginHint, maxAge, ephemeral. |
| signUp() | signIn with prompt=create: opens Danimo's sign-up page. |
| signOut({ endSession? }) | Revokes the refresh token, clears secure storage and memory. With endSession: true it also opens Danimo's end-session page (see below). |
| getAccessToken() | A fresh access token or null. Throws network_error-style DanimoErrors only when Danimo cannot be reached; the session stays. |
| createNativeAuth(config) | The same thing without React: <DanimoProvider client={auth}>, or call auth.attach() yourself. |
| signIn(), signOut(), getAccessToken() as imports | Work outside React (an API client, a navigation guard) while a DanimoProvider is mounted. |
Config
| Option | Default | |
|---|---|---|
| issuer, clientId, redirectUri | required | Issuer exactly as Danimo shows it. |
| scopes | openid profile email offline_access | offline_access is what lets the session survive restarts. |
| resource | none | The API the access token is for. Danimo falls back to the app's default audience. |
| ephemeral | false | iOS: preferEphemeralSession. A private browser session that shares no cookies with Safari. See below. |
| endSessionOnSignOut | false | Also end the Danimo session in the browser. |
| postLogoutRedirectUri | redirectUri | Must be registered in Danimo. A native app's default is its redirect URI. |
| verifyIdTokenSignature | "auto" | See "Hermes and Web Crypto". |
What it does, and why
- Checks everything on return.
stateandissmust match, the return must be exactly yourredirectUri, and the ID token'siss,aud,nonceandexpare checked. A response that fails any check is an error, and nothing is stored. - PKCE S256 on every sign-in, with a fresh state, nonce and verifier. Where the JS engine has no Web Crypto, random bytes and SHA-256 come from
expo-crypto. The platform's own implementation is never replaced. - Storage. Only the refresh token is saved, in
expo-secure-store, asAFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY: readable by a background refresh after the first unlock since boot, and excluded from backups and device transfers (a restored phone signs in again instead of cloning a session). The issuer is saved with it, so a token for another issuer (a tunnel address that changed) is never sent. Access and ID tokens never touch disk. - Launch. Reads the refresh token, refreshes, then loads the user from the userinfo endpoint. A rejected token (session ended) signs out quietly and deletes it. If Danimo cannot be reached, the app shows signed out with a
network_error-styleerror, keeps the token, and tries again when the app next comes to the foreground. - Refresh is single-flight. Danimo rotates refresh tokens and treats a second use of a rotated one as theft, which ends the session. The timer, the foreground handler and every
getAccessToken()call share one request. If saving a rotated token fails, the new token is kept in memory and used next, never the old one. Sign-out discards a refresh that finishes afterwards and revokes its token. - Sign-out revokes the refresh token at Danimo, then clears storage. If the revoke call fails, you are still signed out locally and the token expires on its own.
- Errors are
DanimoErrors with a stablecode. Messages say what happened and how to fix it. No token, code or secret is ever logged.
Web sign-in is shared with this app, unless you say otherwise
On iOS, ASWebAuthenticationSession shares cookies with Safari, and on Android Custom Tabs share the browser's. So a user who signed in to Danimo in the browser signs in to your app without typing a password (single sign-on), and a user who signs out of Danimo in the browser (or in a web app that ends the Danimo session) has this app's refresh token revoked too: the app signs out quietly the next time it refreshes.
- Want the app independent of the browser's Danimo session? Set
ephemeral: true(iOS). Android Custom Tabs have no private mode; the web session is shared there. signOut()alone signs out this app only.signOut({ endSession: true })also ends the Danimo session, which signs out other apps in the same browser.
Hermes and Web Crypto
Stock Hermes has no crypto.subtle, so it cannot verify an ES256 signature. The SDK therefore skips the ID token's signature check only when both of these hold:
crypto.subtle.verifyis missing (with the defaultverifyIdTokenSignature: "auto"). Where it exists, the signature is always verified.- The ID token came directly from Danimo's token endpoint, over HTTPS. OpenID Connect Core §3.1.3.7 allows trusting the TLS connection instead of the signature in that case. The SDK never skips it for a token that arrived any other way, such as a redirect URL. Use an
https://issuer in production: that is what makes the TLS guarantee real. The SDK does not reject anhttp://issuer, so local development againsthttp://localhoststill works.
Every claim is still checked: iss, aud, nonce, exp and iat. Set verifyIdTokenSignature: true to require the signature (sign-in then fails where it can't be checked), or false to always skip it. Access tokens are opaque to the app: your API verifies them.
Limits
- Built and bundled (Metro, iOS and Android) against Expo SDK 57. Older SDKs are untested. Metro must resolve the
exportsof@danimo-hq/coreandjose, andURL/URLSearchParamsmust be complete. - Not yet run on a real device: see
docs/DEVICE_TEST.mdin the repository. - If Android ends your app's process while the browser is open, the sign-in transaction (held in memory) is lost and the user signs in again. Nothing is stored for a half-finished sign-in.
- Not covered yet: universal links / app links as redirect URIs (V1.1), biometric gating of the stored token.
