import { Events, setupAnalytics } from "@openstatus/analytics"; import { db, eq } from "@openstatus/db"; import { type User, user } from "@openstatus/db/src/schema"; import { getCurrency } from "@openstatus/db/src/schema/plan/utils"; import { WelcomeEmail, sendEmail } from "@openstatus/emails"; import type { DefaultSession } from "next-auth"; import NextAuth from "next-auth"; import { headers } from "next/headers"; import { after } from "next/server"; import { cache } from "react"; import { adapter } from "./adapter"; import { logger } from "./logger"; import { GitHubProvider, GoogleProvider, OIDCProvider, ResendProvider, WorkOSProvider, } from "./providers"; import { authorizeSsoSignIn, joinWorkspaceViaSso, readWorkOSProfile, } from "./sso"; const hasWorkOS = Boolean( process.env.AUTH_WORKOS_ID && process.env.AUTH_WORKOS_SECRET, ); export type { DefaultSession }; async function syncUser( userId: number, fields: { firstName?: string | null; lastName?: string | null; photoUrl?: string | null; name?: string | null; }, ) { await db .update(user) .set({ ...fields, updatedAt: new Date() }) .where(eq(user.id, userId)) .run(); } // Runs from the `signIn` event, not `createUser`: only `signIn` carries the // account, and the trial must know the provider to skip SSO signups. It also // fires after the account row is linked, so nothing races the adapter. // Scheduled with `after()`: the Stripe calls take seconds and would otherwise // hold the sign-in redirect. Headers are read up front, before the response. async function onNewUser( newUser: Partial, provider: string | undefined, requestHeaders: Headers, ) { if (!newUser.id || !newUser.email) { throw new Error("User id & email is required"); } // this means the user has already been created with clerk if (newUser.tenantId) return; const currency = getCurrency({ continent: requestHeaders.get("x-vercel-ip-continent") || "NA", country: requestHeaders.get("x-vercel-ip-country") || "US", }); // Imported lazily to keep Stripe out of the proxy bundle, which also loads this module. const { maybeStartSignupTrial } = await import("@openstatus/api/src/router/stripe/trial"); const trial = await maybeStartSignupTrial({ userId: newUser.id, email: newUser.email, provider, currency, }).catch((error: Error) => { console.error("signup trial failed", { userId: newUser.id, error }); return { started: false, reason: "stripe_error" } as const; }); await sendEmail({ from: "Thibault from openstatus ", subject: "Welcome to openstatus.", to: [newUser.email], react: WelcomeEmail({ trialEndsAt: trial.started ? trial.trialEndsAt : undefined, }), }); const analytics = await setupAnalytics({ userId: `usr_${newUser.id}`, email: newUser.email, location: requestHeaders.get("x-forwarded-for") ?? undefined, userAgent: requestHeaders.get("user-agent") ?? undefined, }); await analytics.track(Events.CreateUser); if (trial.started) { await analytics.track({ ...Events.StartTrial, currency }); } else if (trial.reason !== "disabled") { await analytics.track({ ...Events.SkipTrial, reason: trial.reason }); } } const { handlers, signIn, signOut, auth: nextAuth, } = NextAuth({ // debug: true, adapter, logger, providers: [ GitHubProvider, GoogleProvider, ...(process.env.AUTH_OIDC_ISSUER ? [OIDCProvider] : []), ...(hasWorkOS ? [WorkOSProvider] : []), ...(process.env.NODE_ENV === "development" || process.env.SELF_HOST === "true" ? [ResendProvider] : []), ], callbacks: { async redirect({ url, baseUrl }) { // Allow relative URLs, but not protocol-relative `//evil.com` which the // browser would resolve off-origin. if (url.startsWith("/") && !url.startsWith("//")) { return `${baseUrl}${url}`; } // Same-origin absolute URLs only — compare parsed origins, not a string // prefix, so `https://.evil.com` cannot pass as trusted. // Preserves the original callback URL so invite links keep working. try { if (new URL(url).origin === new URL(baseUrl).origin) { return url; } } catch { // malformed url — fall through to baseUrl } return baseUrl; }, async signIn(params) { // We keep updating the user info when we loggin in if (params.account?.provider === "google") { if (!params.profile) return true; if (Number.isNaN(Number(params.user.id))) return true; await syncUser(Number(params.user.id), { firstName: params.profile.given_name, lastName: params.profile.family_name || "", photoUrl: params.profile.picture, // keep the name in sync name: `${params.profile.given_name} ${ params.profile.family_name || "" }`.trim(), }); } if (params.account?.provider === "github") { if (!params.profile) return true; if (Number.isNaN(Number(params.user.id))) return true; await syncUser(Number(params.user.id), { name: params.profile.name, photoUrl: String(params.profile.avatar_url), }); } if (params.account?.provider === "oidc") { if (!params.profile) return true; if (Number.isNaN(Number(params.user.id))) return true; const { given_name, family_name, name, picture } = params.profile; // some IdPs only send a combined `name` claim const [nameFirst, ...nameRest] = name?.split(" ") ?? []; const fullName = [given_name, family_name].filter(Boolean).join(" "); await syncUser(Number(params.user.id), { firstName: given_name ?? nameFirst, lastName: family_name || nameRest.join(" "), photoUrl: picture, name: fullName || name, }); } if (params.account?.provider === "workos") { return authorizeSsoSignIn(readWorkOSProfile(params.profile)); } // REMINDER: only used in dev mode if (params.account?.provider === "resend") { if (Number.isNaN(Number(params.user.id))) return true; await db .update(user) .set({ updatedAt: new Date() }) .where(eq(user.id, Number(params.user.id))) .run(); } return true; }, async session(params) { return params.session; }, }, events: { async signIn(params) { if (params.account?.provider === "workos") { const { organization_id: organizationId } = readWorkOSProfile( params.profile, ); const userId = Number(params.user.id); if (organizationId && !Number.isNaN(userId)) { await joinWorkspaceViaSso({ organizationId, userId }); } } if (params.isNewUser) { const newUser = { ...params.user, id: Number(params.user.id) }; const provider = params.account?.provider; const requestHeaders = new Headers(await headers()); after(() => onNewUser(newUser, provider, requestHeaders).catch((error) => { console.error("onNewUser failed", { userId: newUser.id, error }); }), ); return; } if (!params.user.id || !params.user.email) return; const analytics = await setupAnalytics({ userId: `usr_${params.user.id}`, email: params.user.email, location: (await headers()).get("x-forwarded-for") ?? undefined, userAgent: (await headers()).get("user-agent") ?? undefined, }); await analytics.track(Events.SignInUser); }, }, pages: { signIn: "/login", newUser: "/onboarding", }, // basePath: "/api/auth", // default is `/api/auth` // secret: process.env.AUTH_SECRET, // default is `AUTH_SECRET` debug: process.env.NODE_ENV === "development", }); // The root layout and the tRPC RSC context both need the session; `cache` // collapses them into a single lookup per render. Outside a React render // (the proxy's `auth(handler)` form, route handlers) it is a pass-through. export const auth = cache(nextAuth); export { handlers, signIn, signOut };