import { type CommitResult, type ExpectedSession } from "./session-mutation"; /** * Session manager for React Native environments. * * Drop-in replacement for WebSessionManager that uses a pluggable * StorageAdapter instead of browser localStorage, and a pluggable * base64-decode instead of the browser's atob(). * * Consumers must call ReactNativeSessionManager.configure() once at * startup to supply these adapters (e.g. MMKV, AsyncStorage wrapper, * base64 polyfill). */ /** * Synchronous storage adapter for React Native session persistence. * * The session manager writes the WHOLE credential set - access token, identity * token, and the long-lived (rolling) REFRESH token - through this adapter as a * single JSON string. Whatever `setItem` persists is exactly what an attacker with * device access reads back. * * **You MUST supply an ENCRYPTED backend.** The manager cannot introspect a * caller-supplied adapter to confirm it encrypts at rest, so this is your * responsibility (`allowUnsafePlaintext` on the config is the explicit escape hatch * for the unencrypted case - see `configure()`). Supported encrypted patterns: * - MMKV with an encryption key held in the OS keystore: * `new MMKV({ encryptionKey })` where `encryptionKey` comes from * react-native-keychain / expo-secure-store (NEVER a hardcoded constant) * - expo-secure-store directly * - react-native-keychain directly * * A keyless MMKV instance (constructed with no encryptionKey) or AsyncStorage is * PLAINTEXT: on a rooted/jailbroken device or in a device backup the refresh token * is a portable, replayable credential. */ export interface RNStorageAdapter { getItem(key: string): string | null; setItem(key: string, value: string): void; removeItem(key: string): void; } interface RNSessionConfig { storage: RNStorageAdapter; /** base64 → binary string (replaces browser atob). */ atob: (input: string) => string; /** * Explicit acknowledgment that `storage` is UNENCRYPTED (plaintext). * * The supported/secure default is an encrypted backend (see `configure()`); the * manager cannot verify a caller's adapter actually encrypts, so it cannot enforce * it for you. If you deliberately supply a plaintext adapter (e.g. bare MMKV or * AsyncStorage during development) you MUST set this to `true` to acknowledge that * the access/identity/refresh tokens are readable from a rooted device or a device * backup. It is a conscious-choice gate that also emits a loud one-time warning at * `configure()`; leave it unset for any encrypted backend. */ allowUnsafePlaintext?: boolean; } export declare class ReactNativeSessionManager { private static BOUNDED_SESSION_STORAGE_KEY; /** * Must be called once before any other method. * * SECURITY: the persisted blob includes the long-lived refresh token, so the * `storage` adapter MUST encrypt at rest. The supported pattern is MMKV with an * `encryptionKey` held in the OS keystore (Keychain on iOS / Keystore on Android) * via react-native-keychain - NOT a keyless MMKV instance, and NEVER a hardcoded key. * * ```ts * import { ReactNativeSessionManager } from '@bounded-sh/core'; * import { MMKV } from 'react-native-mmkv'; * import * as Keychain from 'react-native-keychain'; * import { decode } from 'base-64'; * * // Fetch (or lazily mint) a random 256-bit key stored in the OS keystore. * // The keystore - not the app bundle - is the root of trust for the key. * async function getMmkvEncryptionKey(): Promise { * const service = 'sh.bounded.session.mmkv'; * const existing = await Keychain.getGenericPassword({ service }); * if (existing) return existing.password; * const bytes = crypto.getRandomValues(new Uint8Array(32)); * const key = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join(''); * await Keychain.setGenericPassword('bounded', key, { service }); * return key; * } * * const encryptionKey = await getMmkvEncryptionKey(); * const mmkv = new MMKV({ id: 'bounded-session', encryptionKey }); * ReactNativeSessionManager.configure({ * storage: { * getItem: (k) => mmkv.getString(k) ?? null, * setItem: (k, v) => mmkv.set(k, v), * removeItem: (k) => mmkv.delete(k), * }, * atob: decode, * }); * ``` * * Unencrypted adapters (bare MMKV, AsyncStorage) are a credential-theft risk and * require an explicit opt-in - the manager cannot verify encryption for you: * * ```ts * // Development only: acknowledge that tokens are stored in plaintext. * ReactNativeSessionManager.configure({ storage, atob: decode, allowUnsafePlaintext: true }); * ``` */ static configure(cfg: RNSessionConfig): void; /** * Whether configure() has been called. This is the canonical "are we on * React Native?" signal used by getActiveSessionManager(): RN consumers MUST * call configure() at startup, while web/Node never do. Far more reliable * than sniffing `typeof window` (React Native defines a global `window`, so * that test misclassifies RN as web and routes session writes to a * non-existent localStorage). */ static isConfigured(): boolean; private static getStorage; private static decodeBase64; /** * Decode a base64url-encoded string (used by JWT payloads). * Normalises base64url → standard base64 before decoding. */ private static decodeBase64Url; static storeSession(address: string, accessToken: string, idToken: string, refreshToken: string, issuer?: string): Promise; /** Raw read of the stored blob. Callers inside the lock use this, never a cached copy. */ private static readRaw; private static writeRaw; /** Mirror of WebSessionManager.readSessionWithGeneration. */ static readSessionWithGeneration(): Promise; static getSession(): Promise<{ address: string; session: any; } | null>; /** * Remove the session unconditionally. Async and awaited for the same reason as * the web manager: a caller that does not await lets logout() return while the * credential is still readable. */ static clearSession(): Promise; /** Remove only if the stored session is still the one identified by `generation`. */ static clearSessionIfGeneration(generation: string | null): Promise; static isAuthenticated(): boolean; static getIdToken(): string | null; /** The issuer base that minted this session (for routing silent refresh). */ static getIssuer(): string | null; static getRefreshToken(): string | null; /** * Commit refreshed tokens bound to the generation they were minted for. * Mirror of WebSessionManager.commitRefreshedTokens; see it for the rationale. */ static commitRefreshedTokens(expected: ExpectedSession, idToken: string, accessToken: string, refreshToken?: string): Promise>; } export {};