offline-auth
v0.0.1
Published
Offline-first auth primitives for Expo apps with first-class Supabase support.
Readme
hypertill-offline-auth
Offline-first auth primitives for Expo apps, designed around three goals:
- Keep a user signed in locally even when the network drops
- Store tokens in secure storage on native and sensible browser storage on web
- Make biometric app lock easy to layer on top without fighting your auth provider
Supabase is the primary provider in this first cut, but the package is adapter-based so other providers can plug in without changing the React auth surface.
It is meant to be reusable across multiple Expo apps, including hyperbooks, helapoint, and future apps with different auth providers or connectivity rules.
What It Solves
- Secure Expo-native token persistence with
expo-secure-store - Web session persistence with
localStorage - Offline auth snapshot restore so the UI can stay signed in locally
- Optional biometric app lock with
expo-local-authentication - Optional online/offline tracking with
expo-network - A single React auth context instead of scattered auth logic
- Supabase magic links, password login, sign-up, profile updates, session refresh, and deep-link handling
Design Notes
- Passwords are never stored locally by this package.
- Supabase owns the real session; this package stores a local snapshot so the app does not feel logged out during poor connectivity.
- Biometric lock is an app gate, not a replacement for your provider session.
- On native, the recommended storage is
SecureStorewith device-only keychain accessibility. - On web, browser storage is inherently weaker than native secure enclaves, so treat biometric lock as native-first.
Import Strategy
The root package exports the framework-agnostic core:
import {
OfflineAuthProvider,
defineAuthAdapter,
useOfflineAuth,
} from "hypertill-offline-auth";Use subpath imports for optional helpers so each app only pulls what it needs:
import { createSupabaseAuthAdapter } from "hypertill-offline-auth/supabase";
import { createUniversalExpoStorage } from "hypertill-offline-auth/storage";
import { createExpoBiometricLock } from "hypertill-offline-auth/biometric";
import { useOfflineAuthLinking } from "hypertill-offline-auth/linking";
import { createExpoNetworkMonitor } from "hypertill-offline-auth/network";Core Pieces
hypertill-offline-authOfflineAuthProvider,useOfflineAuth(),defineAuthAdapter(), shared types
hypertill-offline-auth/supabasecreateSupabaseAuthAdapter()for Supabase-backed auth
hypertill-offline-auth/storagecreateUniversalExpoStorage()for secure native storage plus web storage
hypertill-offline-auth/biometriccreateExpoBiometricLock()for fingerprint / Face ID / device auth
hypertill-offline-auth/linkinguseOfflineAuthLinking()for Expo deep-link intake with auth-aware auto-stop
hypertill-offline-auth/networkcreateExpoNetworkMonitor()anddefineNetworkMonitor()for online/offline tracking
TypeScript-First Design
AuthAdapteris generic over session, normalized user, profile update payload, and metadata payloadsOfflineAuthProviderpreserves those types through the auth contextuseOfflineAuth()can be specialized per app souser.userMetadatais strongly typed
HyperBooks now defines:
import {
type JsonObject,
type NormalizedAuthUser,
OfflineAuthProvider,
useOfflineAuth as useOfflineAuthBase,
} from "hypertill-offline-auth";
import { createExpoBiometricLock } from "hypertill-offline-auth/biometric";
import { createExpoNetworkMonitor } from "hypertill-offline-auth/network";
import { createSupabaseAuthAdapter } from "hypertill-offline-auth/supabase";
interface HyperbooksAuthMetadata {
shop_name?: string;
phone?: string;
country_code?: string;
country_dial?: string;
}
type HyperbooksAuthUser = NormalizedAuthUser<HyperbooksAuthMetadata>;
export function useOfflineAuth() {
return useOfflineAuthBase<
Session,
HyperbooksAuthUser,
HyperbooksAuthMetadata,
HyperbooksAuthMetadata,
HyperbooksAuthMetadata
>();
}HyperBooks Integration
HyperBooks wires the package like this:
import {
OfflineAuthProvider,
useOfflineAuth,
} from "hypertill-offline-auth";
import { createExpoBiometricLock } from "hypertill-offline-auth/biometric";
import { createExpoNetworkMonitor } from "hypertill-offline-auth/network";
import { createUniversalExpoStorage } from "hypertill-offline-auth/storage";
import { createSupabaseAuthAdapter } from "hypertill-offline-auth/supabase";
const authStorage = createUniversalExpoStorage();
const networkMonitor = createExpoNetworkMonitor();
const supabase = createClient(url, anonKey, {
auth: {
storage: authStorage,
storageKey: "hyperbooks.supabase.auth",
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
},
});
const adapter = createSupabaseAuthAdapter({
supabase,
getMagicLinkRedirectTo: () => Linking.createURL("auth/callback"),
});
<OfflineAuthProvider
adapter={adapter}
storage={authStorage}
biometricLock={createExpoBiometricLock({ promptMessage: "Unlock HyperBooks" })}
networkMonitor={networkMonitor}
storageKey="hyperbooks.offline-auth"
config={{
keepSignedInOffline: true,
biometric: {
enabled: false,
lockOnBackground: true,
backgroundGracePeriodMs: 15_000,
promptMessage: "Unlock HyperBooks",
},
}}
>
<App />
</OfflineAuthProvider>Supabase Flow
Passwordless Magic Link
const auth = useOfflineAuth();
await auth.sendMagicLink({
email: "[email protected]",
metadata: {
shop_name: "Rafiki Stores",
phone: "+254700000000",
},
});Handle Deep Links With The Package
function AuthLinkingBridge() {
useOfflineAuthLinking({
stopWhenAuthenticated: true,
});
return null;
}This hook:
- listens for Expo linking events
- checks both startup URL entrypoints used by dev client and native launches
- deduplicates repeated URLs
- stops listening automatically once the user is authenticated
- starts listening again after sign-out
App Lock
await auth.enableBiometricLock({
promptMessage: "Unlock HyperBooks",
});
await auth.unlock();Tracking Offline Users
useOfflineAuth() now exposes both connectivity state and auth fallback state:
const auth = useOfflineAuth();
const isOffline = auth.network.isOnline === false;
const isUsingOfflineSnapshot = auth.isUsingOfflineSnapshot;Use them together like this:
if (auth.network.isOnline === false) {
// Device has no usable network right now
}
if (auth.isUsingOfflineSnapshot) {
// User is still locally authenticated from the last good session sync
}When a networkMonitor is configured, the provider automatically tries to refresh a snapshot-backed session when connectivity comes back.
Storage Safety
Native secure-store keys are normalized internally so package-managed keys remain valid on iOS and Android, even if your app-level storage key contains characters like :.
If you do not want the built-in Expo helper, you can provide your own monitor:
import { defineNetworkMonitor } from "hypertill-offline-auth/network";
const monitor = defineNetworkMonitor({
async getCurrentState() {
return { isOnline: true, connectionType: "wifi" };
},
subscribe(callback) {
const unsubscribe = myConnectivityApi.onChange((next) => {
callback({
isOnline: next.isReachable,
connectionType: next.transport,
});
});
return unsubscribe;
},
});Using Another Provider
Implement the AuthAdapter interface and pass it to OfflineAuthProvider.
import { defineAuthAdapter } from "hypertill-offline-auth";
const adapter = defineAuthAdapter({
provider: "custom",
capabilities: {
password: true,
},
async getSession() {
return null;
},
getUser(session) {
return session ? { id: "123", email: "[email protected]", phone: null, displayName: null, userMetadata: {}, appMetadata: {} } : null;
},
async signOut() {},
});Security Posture
- Native tokens live in
SecureStore, notAsyncStorage - Passwords are never cached
- Offline fallback uses a normalized session snapshot, not plaintext credentials
- App lock is opt-in and can be enforced after backgrounding
- Sign-out clears the local snapshot and provider session
API Reference
See docs/API.md.
Tests
Run the package test suite with:
npm test