cal.pub0.org/apps/web/pages/api/auth/[...nextauth].tsx

353 lines
10 KiB
TypeScript

import { IdentityProvider } from "@prisma/client";
import NextAuth, { Session } from "next-auth";
import { Provider } from "next-auth/providers";
import CredentialsProvider from "next-auth/providers/credentials";
import GoogleProvider from "next-auth/providers/google";
import { authenticator } from "otplib";
import { ErrorCode, verifyPassword } from "@lib/auth";
import { symmetricDecrypt } from "@lib/crypto";
import prisma from "@lib/prisma";
import { randomString } from "@lib/random";
import { isSAMLLoginEnabled, samlLoginUrl, hostedCal } from "@lib/saml";
import slugify from "@lib/slugify";
import { GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, IS_GOOGLE_LOGIN_ENABLED } from "@server/lib/constants";
const usernameSlug = (username: string) => slugify(username) + "-" + randomString(6).toLowerCase();
const providers: Provider[] = [
CredentialsProvider({
id: "credentials",
name: "Cal.com",
type: "credentials",
credentials: {
email: { label: "Email Address", type: "email", placeholder: "john.doe@example.com" },
password: { label: "Password", type: "password", placeholder: "Your super secure password" },
totpCode: { label: "Two-factor Code", type: "input", placeholder: "Code from authenticator app" },
},
async authorize(credentials) {
if (!credentials) {
console.error(`For some reason credentials are missing`);
throw new Error(ErrorCode.InternalServerError);
}
const user = await prisma.user.findUnique({
where: {
email: credentials.email.toLowerCase(),
},
});
if (!user) {
throw new Error(ErrorCode.UserNotFound);
}
if (user.identityProvider !== IdentityProvider.CAL) {
throw new Error(ErrorCode.ThirdPartyIdentityProviderEnabled);
}
if (!user.password) {
throw new Error(ErrorCode.UserMissingPassword);
}
const isCorrectPassword = await verifyPassword(credentials.password, user.password);
if (!isCorrectPassword) {
throw new Error(ErrorCode.IncorrectPassword);
}
if (user.twoFactorEnabled) {
if (!credentials.totpCode) {
throw new Error(ErrorCode.SecondFactorRequired);
}
if (!user.twoFactorSecret) {
console.error(`Two factor is enabled for user ${user.id} but they have no secret`);
throw new Error(ErrorCode.InternalServerError);
}
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
console.error(`"Missing encryption key; cannot proceed with two factor login."`);
throw new Error(ErrorCode.InternalServerError);
}
const secret = symmetricDecrypt(user.twoFactorSecret, process.env.CALENDSO_ENCRYPTION_KEY);
if (secret.length !== 32) {
console.error(
`Two factor secret decryption failed. Expected key with length 32 but got ${secret.length}`
);
throw new Error(ErrorCode.InternalServerError);
}
const isValidToken = authenticator.check(credentials.totpCode, secret);
if (!isValidToken) {
throw new Error(ErrorCode.IncorrectTwoFactorCode);
}
}
return {
id: user.id,
username: user.username,
email: user.email,
name: user.name,
};
},
}),
];
if (IS_GOOGLE_LOGIN_ENABLED) {
providers.push(
GoogleProvider({
clientId: GOOGLE_CLIENT_ID,
clientSecret: GOOGLE_CLIENT_SECRET,
})
);
}
if (isSAMLLoginEnabled) {
providers.push({
id: "saml",
name: "BoxyHQ",
type: "oauth",
version: "2.0",
checks: ["pkce", "state"],
authorization: {
url: `${samlLoginUrl}/api/auth/saml/authorize`,
params: {
scope: "",
response_type: "code",
provider: "saml",
},
},
token: {
url: `${samlLoginUrl}/api/auth/saml/token`,
params: { grant_type: "authorization_code" },
},
userinfo: `${samlLoginUrl}/api/auth/saml/userinfo`,
profile: (profile) => {
return {
id: profile.id || "",
firstName: profile.firstName || "",
lastName: profile.lastName || "",
email: profile.email || "",
name: `${profile.firstName || ""} ${profile.lastName || ""}`.trim(),
email_verified: true,
};
},
options: {
clientId: "dummy",
clientSecret: "dummy",
},
});
}
export default NextAuth({
session: {
strategy: "jwt",
},
secret: process.env.JWT_SECRET,
pages: {
signIn: "/auth/login",
signOut: "/auth/logout",
error: "/auth/error", // Error code passed in query string as ?error=
},
providers,
callbacks: {
async jwt({ token, user, account }) {
const autoMergeIdentities = async () => {
if (!hostedCal) {
const existingUser = await prisma.user.findFirst({
where: { email: token.email! },
});
if (!existingUser) {
return token;
}
return {
id: existingUser.id,
username: existingUser.username,
email: existingUser.email,
};
}
return token;
};
if (!user) {
return await autoMergeIdentities();
}
if (account && account.type === "credentials") {
return {
id: user.id,
username: user.username,
email: user.email,
};
}
// The arguments above are from the provider so we need to look up the
// user based on those values in order to construct a JWT.
if (account && account.type === "oauth" && account.provider && account.providerAccountId) {
let idP: IdentityProvider = IdentityProvider.GOOGLE;
if (account.provider === "saml") {
idP = IdentityProvider.SAML;
}
const existingUser = await prisma.user.findFirst({
where: {
AND: [
{
identityProvider: idP,
},
{
identityProviderId: account.providerAccountId as string,
},
],
},
});
if (!existingUser) {
return await autoMergeIdentities();
}
return {
id: existingUser.id,
username: existingUser.username,
email: existingUser.email,
};
}
return token;
},
async session({ session, token }) {
const calendsoSession: Session = {
...session,
user: {
...session.user,
id: token.id as number,
username: token.username as string,
},
};
return calendsoSession;
},
async signIn({ user, account, profile }) {
// In this case we've already verified the credentials in the authorize
// callback so we can sign the user in.
if (account.type === "credentials") {
return true;
}
if (account.type !== "oauth") {
return false;
}
if (!user.email) {
return false;
}
if (!user.name) {
return false;
}
if (account.provider) {
let idP: IdentityProvider = IdentityProvider.GOOGLE;
if (account.provider === "saml") {
idP = IdentityProvider.SAML;
}
user.email_verified = user.email_verified || profile.email_verified;
if (!user.email_verified) {
return "/auth/error?error=unverified-email";
}
const existingUser = await prisma.user.findFirst({
where: {
AND: [{ identityProvider: idP }, { identityProviderId: user.id as string }],
},
});
if (existingUser) {
// In this case there's an existing user and their email address
// hasn't changed since they last logged in.
if (existingUser.email === user.email) {
return true;
}
// If the email address doesn't match, check if an account already exists
// with the new email address. If it does, for now we return an error. If
// not, update the email of their account and log them in.
const userWithNewEmail = await prisma.user.findFirst({
where: { email: user.email },
});
if (!userWithNewEmail) {
await prisma.user.update({ where: { id: existingUser.id }, data: { email: user.email } });
return true;
} else {
return "/auth/error?error=new-email-conflict";
}
}
// If there's no existing user for this identity provider and id, create
// a new account. If an account already exists with the incoming email
// address return an error for now.
const existingUserWithEmail = await prisma.user.findFirst({
where: { email: user.email },
});
if (existingUserWithEmail) {
// if self-hosted then we can allow auto-merge of identity providers if email is verified
if (!hostedCal && existingUserWithEmail.emailVerified) {
return true;
}
// check if user was invited
if (
!existingUserWithEmail.password &&
!existingUserWithEmail.emailVerified &&
!existingUserWithEmail.username
) {
await prisma.user.update({
where: { email: user.email },
data: {
// Slugify the incoming name and append a few random characters to
// prevent conflicts for users with the same name.
username: usernameSlug(user.name),
emailVerified: new Date(Date.now()),
name: user.name,
identityProvider: idP,
identityProviderId: user.id as string,
},
});
return true;
}
if (existingUserWithEmail.identityProvider === IdentityProvider.CAL) {
return "/auth/error?error=use-password-login";
}
return "/auth/error?error=use-identity-login";
}
await prisma.user.create({
data: {
// Slugify the incoming name and append a few random characters to
// prevent conflicts for users with the same name.
username: usernameSlug(user.name),
emailVerified: new Date(Date.now()),
name: user.name,
email: user.email,
identityProvider: idP,
identityProviderId: user.id as string,
},
});
return true;
}
return false;
},
},
});