From 4b75bf7cce2f1d5d8bae58b48b0f301ef322edd5 Mon Sep 17 00:00:00 2001 From: alannnc Date: Tue, 26 Apr 2022 09:12:08 -0600 Subject: [PATCH] Fix/login with provider (#2594) --- apps/web/lib/auth/next-auth-custom-adapter.ts | 85 +++++++++++++++++++ apps/web/pages/api/auth/[...nextauth].tsx | 35 ++++++-- apps/web/pages/auth/logout.tsx | 6 ++ .../migration.sql | 39 +++++++++ packages/prisma/schema.prisma | 29 +++++++ 5 files changed, 189 insertions(+), 5 deletions(-) create mode 100644 apps/web/lib/auth/next-auth-custom-adapter.ts create mode 100644 packages/prisma/migrations/20220423175732_added_next_auth_models/migration.sql diff --git a/apps/web/lib/auth/next-auth-custom-adapter.ts b/apps/web/lib/auth/next-auth-custom-adapter.ts new file mode 100644 index 00000000..1fda3b95 --- /dev/null +++ b/apps/web/lib/auth/next-auth-custom-adapter.ts @@ -0,0 +1,85 @@ +import { Account, IdentityProvider, Prisma, PrismaClient, User, VerificationToken } from "@prisma/client"; + +import { identityProviderNameMap } from "@lib/auth"; + +/** @return { import("next-auth/adapters").Adapter } */ +export default function CalComAdapter(prismaClient: PrismaClient) { + return { + createUser: (data: Prisma.UserCreateInput) => prismaClient.user.create({ data }), + getUser: (id: User["id"]) => prismaClient.user.findUnique({ where: { id } }), + getUserByEmail: (email: User["email"]) => prismaClient.user.findUnique({ where: { email } }), + async getUserByAccount(provider_providerAccountId: { + providerAccountId: Account["providerAccountId"]; + provider: User["identityProvider"]; + }) { + let _account; + const account = await prismaClient.account.findUnique({ + where: { + provider_providerAccountId, + }, + select: { user: true }, + }); + if (account) { + return (_account = account === null || account === void 0 ? void 0 : account.user) !== null && + _account !== void 0 + ? _account + : null; + } + + // NOTE: this code it's our fallback to users without Account but credentials in User Table + // We should remove this code after all googles tokens have expired + const provider = provider_providerAccountId?.provider.toUpperCase() as IdentityProvider; + if (["GOOGLE", "SAML"].indexOf(provider) < 0) { + return null; + } + const obtainProvider = identityProviderNameMap[provider].toUpperCase() as IdentityProvider; + const user = await prismaClient.user.findFirst({ + where: { + identityProviderId: provider_providerAccountId?.providerAccountId, + identityProvider: obtainProvider, + }, + }); + return user || null; + }, + updateUser: ({ id, ...data }: Prisma.UserUncheckedCreateInput) => + prismaClient.user.update({ where: { id }, data }), + deleteUser: (id: User["id"]) => prismaClient.user.delete({ where: { id } }), + async createVerificationToken(data: VerificationToken) { + const { id: _, ...verificationToken } = await prismaClient.verificationToken.create({ + data, + }); + return verificationToken; + }, + async useVerificationToken(identifier_token: Prisma.VerificationTokenIdentifierTokenCompoundUniqueInput) { + try { + const { id: _, ...verificationToken } = await prismaClient.verificationToken.delete({ + where: { identifier_token }, + }); + return verificationToken; + } catch (error) { + // If token already used/deleted, just return null + // https://www.prisma.io/docs/reference/api-reference/error-reference#p2025 + // @ts-ignore + if (error.code === "P2025") return null; + throw error; + } + }, + linkAccount: (data: Prisma.AccountCreateInput) => prismaClient.account.create({ data }), + // @NOTE: All methods below here are not being used but leaved if they are required + unlinkAccount: (provider_providerAccountId: Prisma.AccountProviderProviderAccountIdCompoundUniqueInput) => + prismaClient.account.delete({ where: { provider_providerAccountId } }), + async getSessionAndUser(sessionToken: string) { + const userAndSession = await prismaClient.session.findUnique({ + where: { sessionToken }, + include: { user: true }, + }); + if (!userAndSession) return null; + const { user, ...session } = userAndSession; + return { user, session }; + }, + createSession: (data: Prisma.SessionCreateInput) => prismaClient.session.create({ data }), + updateSession: (data: Prisma.SessionWhereUniqueInput) => + prismaClient.session.update({ where: { sessionToken: data.sessionToken }, data }), + deleteSession: (sessionToken: string) => prismaClient.session.delete({ where: { sessionToken } }), + }; +} diff --git a/apps/web/pages/api/auth/[...nextauth].tsx b/apps/web/pages/api/auth/[...nextauth].tsx index aa827d36..68aec250 100644 --- a/apps/web/pages/api/auth/[...nextauth].tsx +++ b/apps/web/pages/api/auth/[...nextauth].tsx @@ -18,6 +18,7 @@ import { serverConfig } from "@calcom/lib/serverConfig"; import ImpersonationProvider from "@ee/lib/impersonation/ImpersonationProvider"; import { ErrorCode, verifyPassword } from "@lib/auth"; +import CalComAdapter from "@lib/auth/next-auth-custom-adapter"; import prisma from "@lib/prisma"; import { randomString } from "@lib/random"; import { hostedCal, isSAMLLoginEnabled, samlLoginUrl } from "@lib/saml"; @@ -185,9 +186,10 @@ if (true) { }) ); } - +const calcomAdapter = CalComAdapter(prisma); export default NextAuth({ - adapter: PrismaAdapter(prisma), + // @ts-ignore + adapter: calcomAdapter, session: { strategy: "jwt", }, @@ -246,7 +248,6 @@ export default NextAuth({ if (account.provider === "saml") { idP = IdentityProvider.SAML; } - const existingUser = await prisma.user.findFirst({ where: { AND: [ @@ -292,6 +293,7 @@ export default NextAuth({ }, async signIn(params) { const { user, account, profile } = params; + if (account.provider === "email") { return true; } @@ -323,10 +325,20 @@ export default NextAuth({ if (!user.email_verified) { return "/auth/error?error=unverified-email"; } + // Only google oauth on this path + const provider = account.provider.toUpperCase() as IdentityProvider; const existingUser = await prisma.user.findFirst({ + include: { + accounts: { + where: { + provider: account.provider, + }, + }, + }, where: { - AND: [{ identityProvider: idP }, { identityProviderId: user.id as string }], + identityProvider: provider, + identityProviderId: account.providerAccountId, }, }); @@ -334,6 +346,17 @@ export default NextAuth({ // 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) { + try { + // If old user without Account entry we link their google account + if (existingUser.accounts.length === 0) { + const linkAccountWithUserData = { ...account, userId: existingUser.id }; + await calcomAdapter.linkAccount(linkAccountWithUserData); + } + } catch (error) { + if (error instanceof Error) { + console.error("Error while linking account of already existing user"); + } + } return true; } @@ -394,7 +417,7 @@ export default NextAuth({ return "/auth/error?error=use-identity-login"; } - await prisma.user.create({ + const newUser = await prisma.user.create({ data: { // Slugify the incoming name and append a few random characters to // prevent conflicts for users with the same name. @@ -406,6 +429,8 @@ export default NextAuth({ identityProviderId: user.id as string, }, }); + const linkAccountNewUserData = { ...account, userId: newUser.id }; + await calcomAdapter.linkAccount(linkAccountNewUserData); return true; } diff --git a/apps/web/pages/auth/logout.tsx b/apps/web/pages/auth/logout.tsx index a57a02c6..22673e1c 100644 --- a/apps/web/pages/auth/logout.tsx +++ b/apps/web/pages/auth/logout.tsx @@ -1,6 +1,7 @@ import { CheckIcon } from "@heroicons/react/outline"; import { GetServerSidePropsContext } from "next"; import { useSession, signOut } from "next-auth/react"; +import { getCookieParser } from "next/dist/server/api-utils"; import Link from "next/link"; import { useRouter } from "next/router"; import { useEffect } from "react"; @@ -51,6 +52,11 @@ export default function Logout(props: Props) { export async function getServerSideProps(context: GetServerSidePropsContext) { const ssr = await ssrInit(context); + // Deleting old cookie manually, remove this code after all existing cookies have expired + context.res.setHeader( + "Set-Cookie", + "next-auth.session-token=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;" + ); return { props: { diff --git a/packages/prisma/migrations/20220423175732_added_next_auth_models/migration.sql b/packages/prisma/migrations/20220423175732_added_next_auth_models/migration.sql new file mode 100644 index 00000000..b50254ba --- /dev/null +++ b/packages/prisma/migrations/20220423175732_added_next_auth_models/migration.sql @@ -0,0 +1,39 @@ +-- CreateTable +CREATE TABLE "Account" ( + "id" TEXT NOT NULL, + "userId" INTEGER NOT NULL, + "type" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "providerAccountId" TEXT NOT NULL, + "refresh_token" TEXT, + "access_token" TEXT, + "expires_at" INTEGER, + "token_type" TEXT, + "scope" TEXT, + "id_token" TEXT, + "session_state" TEXT, + + CONSTRAINT "Account_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Session" ( + "id" TEXT NOT NULL, + "sessionToken" TEXT NOT NULL, + "userId" INTEGER NOT NULL, + "expires" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Session_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken"); + +-- AddForeignKey +ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 2cf1c37e..41dc5d1a 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -164,6 +164,8 @@ model User { impersonatedUsers Impersonations[] @relation("impersonated_user") impersonatedBy Impersonations[] @relation("impersonated_by_user") apiKeys ApiKey[] + accounts Account[] + sessions Session[] @@map(name: "users") } @@ -403,3 +405,30 @@ model ApiKey { hashedKey String @unique() user User? @relation(fields: [userId], references: [id], onDelete: Cascade) } + +model Account { + id String @id @default(cuid()) + userId Int + type String + provider String + providerAccountId String + refresh_token String? @db.Text + access_token String? @db.Text + expires_at Int? + token_type String? + scope String? + id_token String? @db.Text + session_state String? + + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([provider, providerAccountId]) +} + +model Session { + id String @id @default(cuid()) + sessionToken String @unique + userId Int + expires DateTime + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) +}