rallly/apps/web/src/auth.ts
2024-11-02 11:50:09 +00:00

345 lines
9.8 KiB
TypeScript

import { prisma } from "@rallly/database";
import { absoluteUrl } from "@rallly/utils/absolute-url";
import type {
GetServerSidePropsContext,
NextApiRequest,
NextApiResponse,
} from "next";
import type { NextAuthOptions, User } from "next-auth";
import NextAuth, {
getServerSession as getServerSessionWithOptions,
} from "next-auth/next";
import AzureADProvider from "next-auth/providers/azure-ad";
import CredentialsProvider from "next-auth/providers/credentials";
import EmailProvider from "next-auth/providers/email";
import GoogleProvider from "next-auth/providers/google";
import type { Provider } from "next-auth/providers/index";
import { posthog } from "@/app/posthog";
import { CustomPrismaAdapter } from "@/auth/custom-prisma-adapter";
import { mergeGuestsIntoUser } from "@/auth/merge-user";
import { env } from "@/env";
import type { RegistrationTokenPayload } from "@/trpc/types";
import { getEmailClient } from "@/utils/emails";
import { getValueByPath } from "@/utils/get-value-by-path";
import { generateOtp, randomid } from "@/utils/nanoid";
import { decryptToken } from "@/utils/session";
const providers: Provider[] = [
// When a user registers, we don't want to go through the email verification process
// so this provider allows us exchange the registration token for a session token
CredentialsProvider({
id: "registration-token",
name: "Registration Token",
credentials: {
token: {
label: "Token",
type: "text",
},
},
async authorize(credentials) {
if (credentials?.token) {
const payload = await decryptToken<RegistrationTokenPayload>(
credentials.token,
);
if (payload) {
const user = await prisma.user.findUnique({
where: {
email: payload.email,
},
select: {
id: true,
email: true,
name: true,
locale: true,
timeFormat: true,
timeZone: true,
image: true,
},
});
if (user) {
return user;
}
}
}
return null;
},
}),
CredentialsProvider({
id: "guest",
name: "Guest",
credentials: {},
async authorize() {
return {
id: `user-${randomid()}`,
email: null,
};
},
}),
EmailProvider({
server: "",
from: process.env.NOREPLY_EMAIL,
generateVerificationToken() {
return generateOtp();
},
async sendVerificationRequest({ identifier: email, token, url }) {
const user = await prisma.user.findUnique({
where: {
email,
},
select: {
name: true,
locale: true,
},
});
if (user) {
await getEmailClient(user.locale ?? undefined).sendTemplate(
"LoginEmail",
{
to: email,
props: {
magicLink: absoluteUrl("/auth/login", {
magicLink: url,
}),
code: token,
},
},
);
}
},
}),
];
// If we have an OAuth provider configured, we add it to the list of providers
if (
process.env.OIDC_DISCOVERY_URL &&
process.env.OIDC_CLIENT_ID &&
process.env.OIDC_CLIENT_SECRET
) {
providers.push({
id: "oidc",
name: process.env.OIDC_NAME ?? "OpenID Connect",
type: "oauth",
wellKnown: process.env.OIDC_DISCOVERY_URL,
authorization: { params: { scope: "openid email profile" } },
clientId: process.env.OIDC_CLIENT_ID,
clientSecret: process.env.OIDC_CLIENT_SECRET,
idToken: true,
checks: ["state"],
allowDangerousEmailAccountLinking: true,
profile(profile) {
return {
id: profile.sub,
name: getValueByPath(profile, env.OIDC_NAME_CLAIM_PATH),
email: getValueByPath(profile, env.OIDC_EMAIL_CLAIM_PATH),
image: getValueByPath(profile, env.OIDC_PICTURE_CLAIM_PATH),
} as User;
},
});
}
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
providers.push(
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
allowDangerousEmailAccountLinking: true,
}),
);
}
if (
process.env.MICROSOFT_TENANT_ID &&
process.env.MICROSOFT_CLIENT_ID &&
process.env.MICROSOFT_CLIENT_SECRET
) {
providers.push(
AzureADProvider({
tenantId: process.env.MICROSOFT_TENANT_ID,
clientId: process.env.MICROSOFT_CLIENT_ID,
clientSecret: process.env.MICROSOFT_CLIENT_SECRET,
wellKnown:
"https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration",
}),
);
}
const getAuthOptions = (...args: GetServerSessionParams) =>
({
adapter: CustomPrismaAdapter(prisma),
secret: process.env.SECRET_PASSWORD,
session: {
strategy: "jwt",
},
providers: providers,
pages: {
signIn: "/login",
error: "/auth/error",
},
events: {
signIn({ user, account }) {
posthog?.capture({
distinctId: user.id,
event: "login",
properties: {
method: account?.provider,
$set: {
name: user.name,
email: user.email,
timeZone: user.timeZone,
locale: user.locale,
},
},
});
},
},
callbacks: {
async signIn({ user, email, profile }) {
const distinctId = user.id;
// prevent sign in if email is not verified
if (
profile &&
"email_verified" in profile &&
profile.email_verified === false
) {
posthog?.capture({
distinctId,
event: "login failed",
properties: {
reason: "email not verified",
},
});
return false;
}
// Make sure email is allowed
if (user.email) {
const isBlocked = isEmailBlocked(user.email);
if (isBlocked) {
return false;
}
}
// For now, we don't allow users to login unless they have
// registered an account. This is just because we need a name
// to display on the dashboard. The flow can be modified so that
// the name is requested after the user has logged in.
if (email?.verificationRequest) {
const isUnregisteredUser =
(await prisma.user.count({
where: {
email: user.email as string,
},
})) === 0;
if (isUnregisteredUser) {
return false;
}
} else {
// merge guest user into newly logged in user
const session = await getServerSession(...args);
if (session && session.user.email === null) {
await mergeGuestsIntoUser(user.id, [session.user.id]);
}
}
return true;
},
async jwt({ token, user, trigger, account, session }) {
if (trigger === "signUp" && account?.providerAccountId) {
// merge accounts assigned to provider account id to the current user id
await mergeGuestsIntoUser(user.id, [account.providerAccountId]);
}
if (session) {
token.locale = session.locale;
token.timeFormat = session.timeFormat;
token.timeZone = session.timeZone;
token.weekStart = session.weekStart;
}
return token;
},
async session({ session, token }) {
if (token.sub?.startsWith("user-")) {
session.user.id = token.sub as string;
session.user.locale = token.locale;
session.user.timeFormat = token.timeFormat;
session.user.timeZone = token.timeZone;
session.user.locale = token.locale;
session.user.weekStart = token.weekStart;
return session;
}
const user = await prisma.user.findUnique({
where: {
id: token.sub as string,
},
select: {
id: true,
name: true,
timeFormat: true,
timeZone: true,
locale: true,
weekStart: true,
email: true,
image: true,
},
});
if (user) {
session.user.id = user.id;
session.user.name = user.name;
session.user.email = user.email;
session.user.image = user.image;
} else {
session.user.id = token.sub || `user-${randomid()}`;
}
const source = user ?? token;
session.user.locale = source.locale;
session.user.timeFormat = source.timeFormat;
session.user.timeZone = source.timeZone;
session.user.weekStart = source.weekStart;
return session;
},
},
}) satisfies NextAuthOptions;
type GetServerSessionParams =
| [GetServerSidePropsContext["req"], GetServerSidePropsContext["res"]]
| [NextApiRequest, NextApiResponse]
| [];
export async function getServerSession(...args: GetServerSessionParams) {
return await getServerSessionWithOptions(...args, getAuthOptions(...args));
}
export async function AuthApiRoute(req: NextApiRequest, res: NextApiResponse) {
const authOptions = getAuthOptions(req, res);
return NextAuth(req, res, authOptions);
}
export const isEmailBlocked = (email: string) => {
if (process.env.ALLOWED_EMAILS) {
const allowedEmails = process.env.ALLOWED_EMAILS.split(",");
// Check whether the email matches enough of the patterns specified in ALLOWED_EMAILS
const isAllowed = allowedEmails.some((allowedEmail) => {
const regex = new RegExp(
`^${allowedEmail
.replace(/[.+?^${}()|[\]\\]/g, "\\$&")
.replaceAll(/[*]/g, ".*")}$`,
);
return regex.test(email);
});
if (!isAllowed) {
return true;
}
}
return false;
};