import { Platform } from "react-native"; import { apiFetch } from "../utils/api-fetch"; import { extractApiError, extractErrorMessage } from "../utils/http-error"; import { validateEmail as validateEmailUtil, validatePassword as validatePasswordUtil, validateUsername as validateUsernameUtil, } from "../utils/validation"; import * as ApiConfig from "./api-config"; import { clearCachedPolicy as clearHibpPolicyCache } from "./hibp-cache"; // Native persists the user record in SecureStore so the next app launch // has it before /auth/me lands. Web keeps it in process memory only — // sessionStorage is XSS-readable and the cookie-bound session lets us // re-fetch the record from the server on the next mount without a // noticeable UX regression. See SECURITY.md. const USER_DATA_KEY_SUFFIX = "user_data"; let inMemoryUser: User | null = null; function userDataKey(): string { return `${ApiConfig.getStoragePrefix()}_${USER_DATA_KEY_SUFFIX}`; } async function persistUser(user: User): Promise { if (Platform.OS === "web") { inMemoryUser = user; return; } await ApiConfig.kvPut(userDataKey(), JSON.stringify(user)); } async function clearPersistedUser(): Promise { if (Platform.OS === "web") { inMemoryUser = null; return; } await ApiConfig.kvPut(userDataKey(), null); } /** * Normalize a login/register response body into an {@link AuthResponse}. * Single-tenant servers wrap the user as `{ user, session_token }`; * the multi-tenant IdP (access) returns the user record directly and * establishes the session via an HttpOnly cookie (no bearer token). */ async function parseAuthResponse(response: Response): Promise { const json: unknown = await response.json(); if (json && typeof json === "object" && "user" in json) { return json as AuthResponse; } return { user: json as User, session_token: "" }; } async function loadPersistedUser(): Promise { if (Platform.OS === "web") { return inMemoryUser; } try { const userJson = await ApiConfig.kvGet(userDataKey()); if (!userJson) { return null; } return JSON.parse(userJson) as User; } catch { return null; } } /** * Shape of the user record returned by Entropy Softworks servers. Apps * may carry additional fields — extend the interface in the consumer if * needed and pass the extended type through `useAuth()`. */ /** * Conventional onboarding-step values produced by Entropy Softworks * servers. Apps may carry additional vendor-specific steps — keep the * field typed as `string` to stay compatible with the openapi-generated * `UserPublic` shape that vault and bootstrap import from their shared * packages. */ export type OnboardingStep = "email_verification" | "mfa_enrollment" | "import" | "complete"; export interface User { id: string; email: string; /** Optional: email-only servers (e.g. Simple Finance) don't issue usernames. */ username?: string; display_name?: string; role: "Admin" | "User"; /** * Pending step in the post-signup onboarding flow. Typed as `string` * (rather than the `OnboardingStep` union) to stay assignment-compatible * with server-generated openapi types; the auth screens compare against * the known constants and treat unknown values as "complete". */ onboarding_step?: string | null; email_verified?: boolean; mfa_enabled?: boolean; mfa_verified?: boolean; created_at?: string; /** * Tenant the account belongs to. Only the multi-tenant IdP (access) sends * these two; the single-tenant products have no tenant and carry a single * `role` rather than a role set. */ tenant_id?: string; tenant_slug?: string; /** Effective role names, as resolved by the IdP (direct, assigned, or via a group). */ roles?: string[]; } export interface AuthResponse { user: User; /** * Bearer token for native clients. Web clients receive an HttpOnly * cookie and ignore this field. */ session_token: string; } /** Public server feature flags from `GET /config`. */ export interface ServerConfig { /** New accounts must enroll a passkey/TOTP during onboarding. */ require_mfa_enrollment: boolean; registration_enabled: boolean; } export interface LoginRequest { email: string; password: string; } export interface RegisterRequest { email: string; /** Optional: omitted for email-only servers (e.g. Simple Finance). */ username?: string; password: string; display_name?: string; } export interface ErrorResponse { error: { message: string; details?: string[]; code?: string; }; } export interface MfaSetupResponse { uri: string; secret: string; recovery_codes: string[]; } /** * Auth client for Entropy Softworks servers. Apps call `configureApi` * (from `@entropy-softworks/ui/services`) at startup so storage keys and * the base URL are wired up before any of these methods run. */ class AuthService { static loadServerUrlOverride = ApiConfig.loadServerUrlOverride; static getBaseUrl = ApiConfig.getBaseUrl; static setBaseUrl = ApiConfig.setBaseUrl; static resetBaseUrl = ApiConfig.resetBaseUrl; static getSessionToken = ApiConfig.getSessionToken; /** * Persist the session credential + user record produced by an * alternative auth path (passkey-finish, magic-link, etc.) so the * rest of `useAuth` / `getCurrentUser` sees the same state the * password `login` flow leaves behind. * * Web: session credential is a server-set HttpOnly cookie; only * the user record persists here, and it lives in process memory * (NOT sessionStorage) so an XSS exploit cannot read it. * Native: token goes through SecureStore, user record goes * through SecureStore alongside it. */ static async persistAuthSession(data: AuthResponse): Promise { // Both writes target independent storage keys; parallelising them // saves one round-trip on the login / register hot path. await Promise.all([ApiConfig.storeSessionToken(data.session_token), persistUser(data.user)]); } static async login(email: string, password: string): Promise { // NFC-normalize every password that crosses the wire so the // server-side auth hash agrees with whatever key the client // derived from the same string. Without this a master password // typed on a keyboard / IME that emits combining-character // sequences on one device and precomposed forms on another // produces two different hashes — login looks like wrong // password while everything visually matches. See // `shared/crypto/webcrypto.ts deriveMasterKey` for the // client-side twin. const pw = password.normalize("NFC"); // Multi-tenant IdP (access): { tenant, identifier, password } where the // identifier is an email OR username and the tenant is contextual (set // via ApiConfig.setTenant from the OIDC flow). Single-tenant servers use // the { email, password } contract. `email` is the identifier either way. const body = ApiConfig.getAuthMode() === "multi-tenant" ? { tenant: ApiConfig.getTenant(), identifier: email, password: pw } : ({ email, password: pw } satisfies LoginRequest); const response = await apiFetch("/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!response.ok) { throw await extractApiError(response, "Login failed"); } const data = await parseAuthResponse(response); await AuthService.persistAuthSession(data); return data; } /** * Create an account. * * There is no username. The email address is the identity, and the server * derives whatever internal handle it needs from it. * * This used to take one, and the screens built it from the email's local part: * `me@example.com` became `me`, which the server rejected as too short, and * `geoff@gmail.com` and `geoff@outlook.com` both became `geoff`, which * collided on a unique column and refused the second person's signup over a * name they had never chosen. A value the user does not type is a value the * client should not invent. */ static async register( email: string, password: string, displayName?: string ): Promise { const body: RegisterRequest = { email, password: password.normalize("NFC") }; if (displayName !== undefined) { body.display_name = displayName; } const response = await apiFetch("/auth/register", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!response.ok) { throw await extractApiError(response, "Registration failed"); } const data = await parseAuthResponse(response); await AuthService.persistAuthSession(data); return data; } static async logout(): Promise { try { await apiFetch("/auth/logout", { method: "POST" }); } catch (e) { // Logout network call failed — still clear local state below. // Surface the underlying reason in dev so a half-broken logout // (server session still alive after the user thinks they're // gone) doesn't slip past unnoticed. In production we always // clear local state so the UX is "logged out" even if the // server didn't get the request. if (__DEV__) { console.warn("[auth] /auth/logout failed; clearing local state anyway:", e); } } finally { await ApiConfig.clearSessionToken(); await clearPersistedUser(); clearHibpPolicyCache(); } } /** * The signed-in user, from local state if it is there and from the server if * it is not. * * The server fallback is the whole point. On web `loadPersistedUser` returns * a module-level variable, which is null in every freshly loaded document — * so this used to answer "nobody" on each page load and refresh while the * session cookie was perfectly valid. `useAuth.checkAuth` reads that as a * session it cannot resolve and calls `logout()`, which POSTs * `/auth/logout` and destroys the session server-side. The user is signed * out by the act of loading the page. * * Observed on Access on 2026-08-22: the portal and the admin console are * separate SPAs on one origin, so every navigation between them is a fresh * document and therefore a guaranteed logout — which presents as an infinite * sign-in loop. Its audit log recorded the logouts nobody asked for. * * Native was affected too, if less visibly: `isAuthenticated` there is * satisfied by a token in SecureStore, so a token that outlived its cached * user record produced the same spurious logout. * * It does NOT route through `isAuthenticated`. That method's web branch falls * back to calling this one, so going the other way would recurse until the * stack gave out on exactly the signed-out path this is meant to make cheap. */ static async getCurrentUser(): Promise { const persisted = await loadPersistedUser(); if (persisted) { return persisted; } return AuthService.fetchMe(false); } /** * `GET /auth/me`, persisting whatever comes back. * * `logoutOn401` is a parameter rather than a fixed behaviour because the two * callers need opposite things from the same request. A *refresh* of a * session believed live that returns 401 means the session died and local * state should be cleared. A *lookup* that returns 401 means there was no * session to begin with — the ordinary state of a signed-out visitor — and * tearing down on that would fire a logout on every anonymous page load. */ private static async fetchMe(logoutOn401: boolean): Promise { try { const response = await apiFetch("/auth/me", { method: "GET" }); if (!response.ok) { if (logoutOn401 && response.status === 401) { await this.logout(); } return null; } const user: User = await response.json(); await persistUser(user); return user; } catch { return null; } } static async refreshUserData(): Promise { if (!(await this.isAuthenticated())) { return null; } return AuthService.fetchMe(true); } /** * Web: HttpOnly cookies aren't readable from JS. We use the * presence of the (non-HttpOnly) `csrf_token` cookie — set by the * server alongside the session cookie at login — as the * "authenticated" probe. Falls back to the in-memory user record if * cookies are unreadable (SSR / non-browser test envs). * Native: a token in SecureStore. */ static async isAuthenticated(): Promise { if (Platform.OS === "web") { if (typeof document !== "undefined" && document.cookie) { if (/(?:^|;\s*)csrf_token=/.test(document.cookie)) { return true; } } return (await this.getCurrentUser()) !== null; } const token = await ApiConfig.getSessionToken(); return token !== null; } static async hasRole(role: "Admin" | "User"): Promise { const user = await this.getCurrentUser(); return user?.role === role; } static async deleteAccount(password: string): Promise { const response = await apiFetch("/auth/delete", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: password.normalize("NFC") }), }); if (!response.ok) { throw new Error(await extractErrorMessage(response, "Failed to delete account")); } await ApiConfig.clearSessionToken(); await clearPersistedUser(); } static async changePassword( currentPassword: string, newPassword: string, reWrappedKey?: { symmetric_key_enc: string; kdf: string; kdf_salt: string; kdf_iterations: number; kdf_memory_kib?: number; kdf_lanes?: number; } ): Promise { const body: Record = { current_password: currentPassword.normalize("NFC"), new_password: newPassword.normalize("NFC"), }; if (reWrappedKey) { body.symmetric_key_enc = reWrappedKey.symmetric_key_enc; body.kdf = reWrappedKey.kdf; body.kdf_salt = reWrappedKey.kdf_salt; body.kdf_iterations = reWrappedKey.kdf_iterations; body.kdf_memory_kib = reWrappedKey.kdf_memory_kib; body.kdf_lanes = reWrappedKey.kdf_lanes; } const response = await apiFetch("/auth/password", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!response.ok) { throw new Error(await extractErrorMessage(response, "Failed to change password")); } } static async changeEmail(newEmail: string, password: string): Promise { const response = await apiFetch("/auth/email", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ new_email: newEmail, password: password.normalize("NFC") }), }); if (!response.ok) { throw new Error(await extractErrorMessage(response, "Failed to change email")); } } static async updateDisplayName(displayName: string): Promise { const response = await apiFetch("/auth/display-name", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ display_name: displayName }), }); if (!response.ok) { throw new Error(await extractErrorMessage(response, "Failed to update display name")); } } static async setupMfa(): Promise { const response = await apiFetch("/auth/mfa/setup", { method: "POST" }); if (!response.ok) { throw new Error(await extractErrorMessage(response, "Failed to set up MFA")); } return await response.json(); } static async verifyMfaSetup(code: string): Promise { const response = await apiFetch("/auth/mfa/verify-setup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code }), }); if (!response.ok) { throw new Error(await extractErrorMessage(response, "Invalid verification code")); } } static async verifyMfa(code: string): Promise { const response = await apiFetch("/auth/mfa/verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code }), }); if (!response.ok) { throw new Error(await extractErrorMessage(response, "Invalid MFA code")); } } static async disableMfa(password: string): Promise { const response = await apiFetch("/auth/mfa", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: password.normalize("NFC") }), }); if (!response.ok) { throw new Error(await extractErrorMessage(response, "Failed to disable MFA")); } } static async forgotPassword(email: string): Promise { // Multi-tenant IdP scopes the reset request to a tenant. const body = ApiConfig.getAuthMode() === "multi-tenant" ? { tenant: ApiConfig.getTenant(), email } : { email }; const response = await apiFetch("/auth/forgot-password", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!response.ok) { throw new Error(await extractErrorMessage(response, "Failed to send reset email")); } } static async validateResetCode(code: string): Promise { const response = await apiFetch("/auth/validate-reset-code", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code }), }); if (!response.ok) { throw await extractApiError(response, "Invalid or expired code"); } } static async resetPassword(code: string, newPassword: string): Promise { const response = await apiFetch("/auth/reset-password", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code, new_password: newPassword.normalize("NFC") }), }); if (!response.ok) { throw await extractApiError(response, "Failed to reset password"); } } static async verifyEmail(code: string): Promise { const response = await apiFetch("/auth/verify-email", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code }), }); if (!response.ok) { throw new Error(await extractErrorMessage(response, "Email verification failed")); } } static async resendVerification(): Promise { const response = await apiFetch("/auth/resend-verification", { method: "POST" }); if (!response.ok) { throw new Error(await extractErrorMessage(response, "Failed to resend verification email")); } } static async completeOnboarding(): Promise { const response = await apiFetch("/auth/complete-onboarding", { method: "POST" }); if (!response.ok) { throw new Error(await extractErrorMessage(response, "Failed to complete onboarding")); } } /** * Public server configuration (feature flags) — read pre-auth to adapt the * UI, e.g. whether MFA enrollment is mandatory during onboarding. Returns * permissive defaults if the endpoint is unavailable so onboarding never * hard-blocks on a missing/older server. */ static async getServerConfig(): Promise { try { const response = await apiFetch("/config", { method: "GET" }); if (!response.ok) { return { require_mfa_enrollment: false, registration_enabled: true }; } return (await response.json()) as ServerConfig; } catch { return { require_mfa_enrollment: false, registration_enabled: true }; } } // Validator hints kept on the class as re-exports of the canonical // free functions in `utils/validation`. The server enforces the same // rules authoritatively (see entropy-auth's `password::policy`); the // versions here are UI-only previews so the user sees feedback before // the round-trip. static validatePassword = validatePasswordUtil; static validateEmail = validateEmailUtil; static validateUsername = validateUsernameUtil; } export default AuthService;