← back to Govarbitrage

src/app/api/auth/apple/route.ts

105 lines

import { NextRequest, NextResponse } from "next/server";
import { randomBytes } from "node:crypto";
import { z } from "zod";
import { createRemoteJWKSet, jwtVerify } from "jose";
import { prisma } from "@/lib/db";
import { hashPassword } from "@/lib/password";
import { createSession, SESSION_COOKIE, SESSION_MAX_AGE } from "@/lib/session";
import { rateLimit, clientIp, tooManyRequests } from "@/lib/rate-limit";

export const dynamic = "force-dynamic";

// Sign in with Apple — NATIVE iOS flow. The iOS app (expo-apple-authentication)
// obtains an Apple identity token whose `aud` is the app's bundle id; we verify
// it against Apple's public JWKS and issue our OWN app session JWT in the JSON
// body (the native app stores it and sends `Authorization: Bearer <jwt>`). A
// session cookie is also set for web parity. Optional sign-in: the rest of the
// app works logged-out; this just creates/links a lightweight account.
const APPLE_ISSUER = "https://appleid.apple.com";
const APPLE_JWKS = createRemoteJWKSet(new URL("https://appleid.apple.com/auth/keys"));
// The app's bundle id is the `aud` of a native Sign in with Apple token.
const APPLE_AUDIENCE = process.env.APPLE_BUNDLE_ID || "com.abrams.govarbitrage";

// Apple returns name/email ONLY on the very first authorization; the app passes
// them through so we can populate the account on create.
const Schema = z.object({
  identityToken: z.string().min(1),
  fullName: z.string().trim().max(200).optional(),
  email: z.string().email().max(320).optional(),
});

export async function POST(req: NextRequest) {
  // Brute-force / abuse protection, mirroring the password login route.
  const ip = clientIp(req);
  const ipLimit = rateLimit(`apple:ip:${ip}`, 20, 5 * 60_000);
  if (!ipLimit.allowed) return tooManyRequests(ipLimit);

  const parsed = Schema.safeParse(await req.json().catch(() => ({})));
  if (!parsed.success) {
    return NextResponse.json({ error: "identityToken required" }, { status: 400 });
  }
  const { identityToken, fullName, email: providedEmail } = parsed.data;

  // Verify the Apple identity token: signature (Apple JWKS) + issuer + audience.
  let appleSub: string;
  let tokenEmail: string | undefined;
  try {
    const { payload } = await jwtVerify(identityToken, APPLE_JWKS, {
      issuer: APPLE_ISSUER,
      audience: APPLE_AUDIENCE,
    });
    if (!payload.sub) throw new Error("no sub");
    appleSub = payload.sub;
    tokenEmail = typeof payload.email === "string" ? payload.email : undefined;
  } catch {
    await prisma.auditLog.create({
      data: { action: "auth.apple.failed", entity: "User", meta: { reason: "token_verify" } },
    });
    return NextResponse.json({ error: "Invalid Apple token" }, { status: 401 });
  }

  // Prefer the verified email from the token, then the app-provided one, else a
  // stable synthetic (Apple can withhold email on re-auth / private relay).
  const email = (tokenEmail || providedEmail || `apple_${appleSub}@govarbitrage.apple`).toLowerCase();

  // Upsert the account by Apple `sub`. Apple users never password-login, so give
  // them an unguessable random passwordHash (keeps the column non-null without a
  // migration and without a usable password).
  let user = await prisma.user.findUnique({ where: { appleSub } });
  if (!user) {
    user = await prisma.user.create({
      data: {
        appleSub,
        email,
        name: fullName || null,
        passwordHash: hashPassword(randomBytes(32).toString("hex")),
        role: "VIEWER",
      },
    });
    await prisma.auditLog.create({
      data: { userId: user.id, action: "auth.apple.signup", entity: "User", entityId: user.id },
    });
  } else if (fullName && !user.name) {
    // Backfill the name if Apple only sent it now and we didn't have it.
    user = await prisma.user.update({ where: { id: user.id }, data: { name: fullName } });
  }

  const token = await createSession({ sub: user.id, email: user.email, role: user.role });
  await prisma.auditLog.create({
    data: { userId: user.id, action: "auth.apple.login", entity: "User", entityId: user.id },
  });

  const res = NextResponse.json({
    token, // native app stores this and sends it as `Authorization: Bearer <token>`
    user: { id: user.id, email: user.email, name: user.name, role: user.role },
  });
  res.cookies.set(SESSION_COOKIE, token, {
    httpOnly: true,
    sameSite: "lax",
    secure: process.env.NODE_ENV === "production",
    path: "/",
    maxAge: SESSION_MAX_AGE,
  });
  return res;
}