← back to Govarbitrage

apps/mobile/lib/auth.ts

84 lines

/**
 * Sign in with Apple (native iOS). Uses expo-apple-authentication to obtain an
 * Apple identity token, exchanges it at the backend's /api/auth/apple for our
 * own app session JWT, and stores that in expo-secure-store. Optional: the rest
 * of the app works signed-out; this just creates/links a lightweight account.
 */
import * as AppleAuthentication from "expo-apple-authentication";
import {
  loadSettings,
  saveAppleAccount,
  clearAppleAccount,
  type AppleAccount,
} from "./settings";

/** True only on iOS devices/simulators that support Sign in with Apple. */
export async function isAppleSignInAvailable(): Promise<boolean> {
  try {
    return await AppleAuthentication.isAvailableAsync();
  } catch {
    return false;
  }
}

export async function signInWithApple(): Promise<AppleAccount> {
  const credential = await AppleAuthentication.signInAsync({
    requestedScopes: [
      AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
      AppleAuthentication.AppleAuthenticationScope.EMAIL,
    ],
  });

  const { identityToken, fullName, email } = credential;
  if (!identityToken) {
    throw new Error("Apple did not return an identity token.");
  }

  // Apple sends name/email only on the FIRST authorization — pass them through
  // so the backend can populate the account on create.
  const name =
    fullName && (fullName.givenName || fullName.familyName)
      ? [fullName.givenName, fullName.familyName].filter(Boolean).join(" ")
      : null;

  const { baseUrl } = await loadSettings();
  const res = await fetch(`${baseUrl.replace(/\/$/, "")}/api/auth/apple`, {
    method: "POST",
    headers: { "Content-Type": "application/json", Accept: "application/json" },
    body: JSON.stringify({
      identityToken,
      fullName: name || undefined,
      email: email || undefined,
    }),
  });

  if (!res.ok) {
    let msg = `Sign-in failed (${res.status}).`;
    try {
      const body = await res.json();
      if (body?.error) msg = body.error;
    } catch {
      // keep the status-code message
    }
    throw new Error(msg);
  }

  const data = (await res.json()) as {
    token: string;
    user?: { name?: string | null; email?: string | null };
  };
  if (!data?.token) throw new Error("Server did not return a session token.");

  const account: AppleAccount = {
    token: data.token,
    name: data.user?.name ?? name,
    email: data.user?.email ?? email ?? null,
  };
  await saveAppleAccount(account);
  return account;
}

export async function signOut(): Promise<void> {
  await clearAppleAccount();
}