← 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;
}