From 7c03059bc06b706330e8f67707f21e370ad44f2f Mon Sep 17 00:00:00 2001 From: Armand Didierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sun, 26 Nov 2023 05:13:42 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20support=20for=20OpenID=20Conn?= =?UTF-8?q?ect=20(#939)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../self-hosting/configuration-options.mdx | 46 ++++ apps/landing/declarations/environment.d.ts | 43 ++++ apps/web/declarations/environment.d.ts | 20 ++ apps/web/public/locales/en/app.json | 4 +- apps/web/src/app/[locale]/(auth)/layout.tsx | 10 +- .../app/[locale]/(auth)/login/login-form.tsx | 157 ++++++++++++ .../src/app/[locale]/(auth)/login/page.tsx | 30 ++- .../src/app/[locale]/(auth)/register/page.tsx | 24 +- .../(auth)/register/register-page.tsx | 31 +-- apps/web/src/components/auth/auth-forms.tsx | 146 +---------- apps/web/src/components/auth/auth-layout.tsx | 35 +-- apps/web/src/utils/auth.ts | 236 ++++++++++-------- apps/web/src/utils/constants.ts | 4 + .../migration.sql | 20 ++ .../migration.sql | 2 + .../migration.sql | 32 +++ packages/database/prisma/schema.prisma | 22 ++ turbo.json | 5 + 18 files changed, 562 insertions(+), 305 deletions(-) create mode 100644 apps/web/src/app/[locale]/(auth)/login/login-form.tsx create mode 100644 packages/database/prisma/migrations/20231117153753_add_nextauth_provider_accounts/migration.sql create mode 100644 packages/database/prisma/migrations/20231118134458_add_account_user_index/migration.sql create mode 100644 packages/database/prisma/migrations/20231122061137_map_account_table_names/migration.sql diff --git a/apps/docs/self-hosting/configuration-options.mdx b/apps/docs/self-hosting/configuration-options.mdx index d9cb524ab..350158fe9 100644 --- a/apps/docs/self-hosting/configuration-options.mdx +++ b/apps/docs/self-hosting/configuration-options.mdx @@ -68,3 +68,49 @@ These variables need to be configured to let Rallly send out transactional email Enable TLS for your SMTP connection + +### Single Sign On (SSO) with OpenID Connect (OIDC) + +To enable SSO with an OIDC compliant identity provider you will need to configure the following variables. + + + Must be set to `true` to enable OIDC Login + + + + The user-facing name of your provider as it will be shown on the login page + + + + URL of the `.well-known/openid-configuration` endpoint for your OIDC provider + + + + The client ID of your OIDC application + + + + The client secret of your OIDC application + + +#### Required Scopes + +The following scopes are required for OIDC to function properly. + +- `openid`: Essential for OIDC to function, used to perform authentication. +- `profile`: Access to the user's personal information such as name and picture. +- `email`: Access to the user's email address. + +#### Callback URL / Redirect URI + +The callback URL for your OIDC application must be set to: + +``` +{NEXT_PUBLIC_BASE_URL}/api/auth/callback/oidc +``` + + + Replace `{NEXT_PUBLIC_BASE_URL}` with the base URL of your Rallly instance. + + +Ensure this URL is added to the list of allowed redirect URIs in your OIDC provider's application settings. diff --git a/apps/landing/declarations/environment.d.ts b/apps/landing/declarations/environment.d.ts index 2258f7b3b..19d1ec580 100644 --- a/apps/landing/declarations/environment.d.ts +++ b/apps/landing/declarations/environment.d.ts @@ -68,6 +68,49 @@ declare global { * Determines what email provider to use. "smtp" or "ses" */ EMAIL_PROVIDER?: "smtp" | "ses"; + /** + * Name of the oidc provider + */ + OIDC_NAME?: string; + /** + * URL of the oidc provider .well-known/openid-configuration endpoint + */ + OIDC_DISCOVERY_URL?: string; + /** + * Client ID of the oidc provider + */ + OIDC_CLIENT_ID?: string; + /** + * Client secret of the oidc provider + */ + OIDC_CLIENT_SECRET?: string; + /** + * Scopes that should be used when configuring the oidc provider + */ + OIDC_SCOPES?: string; + /** + * If Rallly should expect the oidc provider to return an ID token + */ + OIDC_ID_TOKEN_EXPECTED?: string; + /** + * When using an oidc provider that support the userinfo endpoint, set this to "true" to + * use it instead of getting the user info from the ID token + */ + OIDC_FORCE_USER_INFO?: string; + /** + * When using a provider that does not provide a userinfo endpoint in its discovery document, + * `OIDC_FORCE_USER_INFO` may be set to the URL of the userinfo endpoint. + * `OIDC_USER_INFO_URL` may not be usable when `OIDC_FORCE_USER_INFO` is set to `true` + */ + OIDC_USER_INFO_URL?: string; + /** + * The name of the `name` field returned by the oidc provider + */ + OIDC_NAME_CLAIM?: string; + /** + * The name of the `email` field returned by the oidc provider + */ + OIDC_EMAIL_CLAIM?: string; /** * AWS access key ID */ diff --git a/apps/web/declarations/environment.d.ts b/apps/web/declarations/environment.d.ts index a91ab98d9..ed96abbe1 100644 --- a/apps/web/declarations/environment.d.ts +++ b/apps/web/declarations/environment.d.ts @@ -64,6 +64,26 @@ declare global { * Determines what email provider to use. "smtp" or "ses" */ EMAIL_PROVIDER?: "smtp" | "ses"; + /** + * Set to "true" to enable OIDC authentication + */ + OIDC_ENABLED?: string; + /** + * Name of the oidc provider + */ + OIDC_NAME?: string; + /** + * URL of the oidc provider .well-known/openid-configuration endpoint + */ + OIDC_DISCOVERY_URL?: string; + /** + * Client ID of the oidc provider + */ + OIDC_CLIENT_ID?: string; + /** + * Client secret of the oidc provider + */ + OIDC_CLIENT_SECRET?: string; /** * AWS access key ID */ diff --git a/apps/web/public/locales/en/app.json b/apps/web/public/locales/en/app.json index f9153ac6f..d673ed56a 100644 --- a/apps/web/public/locales/en/app.json +++ b/apps/web/public/locales/en/app.json @@ -40,6 +40,7 @@ "location": "Location", "locationPlaceholder": "Joe's Coffee Shop", "login": "Login", + "loginWith": "Login with {provider}", "logout": "Logout", "manage": "Manage", "mixedOptionsDescription": "You can't have both time and date options in the same poll. Which would you like to keep?", @@ -226,5 +227,6 @@ "continueAs": "Continue as", "finalizeFeature": "Finalize", "duplicateFeature": "Duplicate", - "pageMovedDescription": "Redirecting to {newUrl}" + "pageMovedDescription": "Redirecting to {newUrl}", + "notRegistered": "Don't have an account? Register" } diff --git a/apps/web/src/app/[locale]/(auth)/layout.tsx b/apps/web/src/app/[locale]/(auth)/layout.tsx index 88418aa86..9469b2c6e 100644 --- a/apps/web/src/app/[locale]/(auth)/layout.tsx +++ b/apps/web/src/app/[locale]/(auth)/layout.tsx @@ -1,7 +1,7 @@ -"use client"; - -import { AuthLayout } from "@/components/auth/auth-layout"; - export default function Layout({ children }: { children: React.ReactNode }) { - return {children}; + return ( +
+
{children}
+
+ ); } diff --git a/apps/web/src/app/[locale]/(auth)/login/login-form.tsx b/apps/web/src/app/[locale]/(auth)/login/login-form.tsx new file mode 100644 index 000000000..8e39782bc --- /dev/null +++ b/apps/web/src/app/[locale]/(auth)/login/login-form.tsx @@ -0,0 +1,157 @@ +"use client"; +import { Button } from "@rallly/ui/button"; +import { LogInIcon, UserIcon } from "lucide-react"; +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { signIn, useSession } from "next-auth/react"; +import { usePostHog } from "posthog-js/react"; +import React from "react"; +import { useForm } from "react-hook-form"; +import { Trans, useTranslation } from "react-i18next"; + +import { trpc } from "@/app/providers"; +import { VerifyCode, verifyCode } from "@/components/auth/auth-forms"; +import { TextInput } from "@/components/text-input"; +import { IfCloudHosted } from "@/contexts/environment"; +import { isSelfHosted } from "@/utils/constants"; +import { validEmail } from "@/utils/form-validation"; + +export function LoginForm({ oidcConfig }: { oidcConfig?: { name: string } }) { + const { t } = useTranslation(); + + const { register, handleSubmit, getValues, formState, setError } = useForm<{ + email: string; + }>({ + defaultValues: { email: "" }, + }); + + const session = useSession(); + const queryClient = trpc.useUtils(); + const [email, setEmail] = React.useState(); + const posthog = usePostHog(); + const router = useRouter(); + const callbackUrl = (useSearchParams()?.get("callbackUrl") as string) ?? "/"; + + const hasOIDCProvider = !!oidcConfig; + const allowGuestAccess = !isSelfHosted; + const hasAlternativeLoginMethods = hasOIDCProvider || allowGuestAccess; + const sendVerificationEmail = (email: string) => { + return signIn("email", { + redirect: false, + email, + callbackUrl, + }); + }; + + if (email) { + return ( + { + const success = await verifyCode({ + email, + token: code, + }); + if (!success) { + throw new Error("Failed to authenticate user"); + } else { + queryClient.invalidate(); + const s = await session.update(); + if (s?.user) { + posthog?.identify(s.user.id, { + email: s.user.email, + name: s.user.name, + }); + } + posthog?.capture("login", { + method: "verification-code", + }); + router.push(callbackUrl); + } + }} + email={getValues("email")} + /> + ); + } + + return ( +
{ + const res = await sendVerificationEmail(email); + + if (res?.error) { + setError("email", { + message: t("userNotFound"), + }); + } else { + setEmail(email); + } + })} + > +
{t("login")}
+

+ {t("stepSummary", { + current: 1, + total: 2, + })} +

+
+ + + {formState.errors.email?.message ? ( +
+ {formState.errors.email.message} +
+ ) : null} +
+
+ + {hasAlternativeLoginMethods ? ( + <> +
+
+ + + + {hasOIDCProvider ? ( + + ) : null} +
+ + ) : null} +
+
+ ); +} diff --git a/apps/web/src/app/[locale]/(auth)/login/page.tsx b/apps/web/src/app/[locale]/(auth)/login/page.tsx index 11ffbddc0..c67a65a46 100644 --- a/apps/web/src/app/[locale]/(auth)/login/page.tsx +++ b/apps/web/src/app/[locale]/(auth)/login/page.tsx @@ -1,9 +1,33 @@ +import Link from "next/link"; +import { Trans } from "react-i18next/TransWithoutContext"; + +import { LoginForm } from "@/app/[locale]/(auth)/login/login-form"; import { Params } from "@/app/[locale]/types"; import { getTranslation } from "@/app/i18n"; -import { LoginForm } from "@/components/auth/auth-forms"; +import { AuthCard } from "@/components/auth/auth-layout"; +import { isOIDCEnabled, oidcName } from "@/utils/constants"; -export default function LoginPage() { - return ; +export default async function LoginPage({ params }: { params: Params }) { + const { t } = await getTranslation(params.locale); + return ( +
+ + + +
+ , + }} + /> +
+
+ ); } export async function generateMetadata({ params }: { params: Params }) { diff --git a/apps/web/src/app/[locale]/(auth)/register/page.tsx b/apps/web/src/app/[locale]/(auth)/register/page.tsx index 37c2d8d25..1468797ce 100644 --- a/apps/web/src/app/[locale]/(auth)/register/page.tsx +++ b/apps/web/src/app/[locale]/(auth)/register/page.tsx @@ -1,9 +1,29 @@ +import Link from "next/link"; +import { Trans } from "react-i18next/TransWithoutContext"; + import { RegisterForm } from "@/app/[locale]/(auth)/register/register-page"; import { Params } from "@/app/[locale]/types"; import { getTranslation } from "@/app/i18n"; +import { AuthCard } from "@/components/auth/auth-layout"; -export default function Page() { - return ; +export default async function Page({ params }: { params: Params }) { + const { t } = await getTranslation(params.locale); + return ( +
+ + + +
+ , + }} + /> +
+
+ ); } export async function generateMetadata({ params }: { params: Params }) { diff --git a/apps/web/src/app/[locale]/(auth)/register/register-page.tsx b/apps/web/src/app/[locale]/(auth)/register/register-page.tsx index ff32efb60..2ea6979b0 100644 --- a/apps/web/src/app/[locale]/(auth)/register/register-page.tsx +++ b/apps/web/src/app/[locale]/(auth)/register/register-page.tsx @@ -1,14 +1,13 @@ "use client"; import { Button } from "@rallly/ui/button"; -import Link from "next/link"; import { useParams, useSearchParams } from "next/navigation"; import { signIn } from "next-auth/react"; -import { Trans, useTranslation } from "next-i18next"; +import { useTranslation } from "next-i18next"; import { usePostHog } from "posthog-js/react"; import React from "react"; import { useForm } from "react-hook-form"; -import { useDefaultEmail, VerifyCode } from "@/components/auth/auth-forms"; +import { VerifyCode } from "@/components/auth/auth-forms"; import { TextInput } from "@/components/text-input"; import { useDayjs } from "@/utils/dayjs"; import { requiredString, validEmail } from "@/utils/form-validation"; @@ -19,17 +18,14 @@ type RegisterFormData = { email: string; }; -export const RegisterForm: React.FunctionComponent<{ - onClickLogin?: React.MouseEventHandler; -}> = ({ onClickLogin }) => { - const [defaultEmail, setDefaultEmail] = useDefaultEmail(); +export const RegisterForm = () => { const { t } = useTranslation(); const { timeZone } = useDayjs(); const params = useParams<{ locale: string }>(); const searchParams = useSearchParams(); const { register, handleSubmit, getValues, setError, formState } = useForm({ - defaultValues: { email: defaultEmail }, + defaultValues: { email: "" }, }); const queryClient = trpc.useUtils(); @@ -71,7 +67,6 @@ export const RegisterForm: React.FunctionComponent<{ callbackUrl: searchParams?.get("callbackUrl") ?? undefined, }); }} - onChange={() => setToken(undefined)} email={getValues("email")} /> ); @@ -156,24 +151,6 @@ export const RegisterForm: React.FunctionComponent<{ > {t("continue")} -
- { - setDefaultEmail(getValues("email")); - onClickLogin?.(e); - }} - /> - ), - }} - /> -
); }; diff --git a/apps/web/src/components/auth/auth-forms.tsx b/apps/web/src/components/auth/auth-forms.tsx index f0d3e2496..dc8fce2f9 100644 --- a/apps/web/src/components/auth/auth-forms.tsx +++ b/apps/web/src/components/auth/auth-forms.tsx @@ -1,22 +1,13 @@ "use client"; import { Button } from "@rallly/ui/button"; -import Link from "next/link"; -import { useRouter, useSearchParams } from "next/navigation"; -import { signIn, useSession } from "next-auth/react"; import { Trans, useTranslation } from "next-i18next"; import React from "react"; import { useForm } from "react-hook-form"; -import { createGlobalState } from "react-use"; -import { usePostHog } from "@/utils/posthog"; -import { trpc } from "@/utils/trpc/client"; - -import { requiredString, validEmail } from "../../utils/form-validation"; +import { requiredString } from "../../utils/form-validation"; import { TextInput } from "../text-input"; -export const useDefaultEmail = createGlobalState(""); - -const verifyCode = async (options: { email: string; token: string }) => { +export const verifyCode = async (options: { email: string; token: string }) => { const url = `${ window.location.origin }/api/auth/callback/email?email=${encodeURIComponent(options.email)}&token=${ @@ -31,8 +22,7 @@ const verifyCode = async (options: { email: string; token: string }) => { export const VerifyCode: React.FunctionComponent<{ email: string; onSubmit: (code: string) => Promise; - onChange: () => void; -}> = ({ onChange, onSubmit, email }) => { +}> = ({ onSubmit, email }) => { const { register, handleSubmit, setError, formState } = useForm<{ code: string; }>(); @@ -73,7 +63,6 @@ export const VerifyCode: React.FunctionComponent<{ href="#" onClick={(e) => { e.preventDefault(); - onChange(); }} /> ), @@ -113,132 +102,3 @@ export const VerifyCode: React.FunctionComponent<{ ); }; - -export const LoginForm: React.FunctionComponent<{ - onClickRegister?: ( - e: React.MouseEvent, - email: string, - ) => void; -}> = ({ onClickRegister }) => { - const { t } = useTranslation(); - const [defaultEmail, setDefaultEmail] = useDefaultEmail(); - - const { register, handleSubmit, getValues, formState, setError } = useForm<{ - email: string; - }>({ - defaultValues: { email: defaultEmail }, - }); - - const session = useSession(); - const queryClient = trpc.useUtils(); - const [email, setEmail] = React.useState(); - const posthog = usePostHog(); - const router = useRouter(); - const callbackUrl = (useSearchParams()?.get("callbackUrl") as string) ?? "/"; - - const sendVerificationEmail = (email: string) => { - return signIn("email", { - redirect: false, - email, - callbackUrl, - }); - }; - if (email) { - return ( - { - const success = await verifyCode({ - email, - token: code, - }); - if (!success) { - throw new Error("Failed to authenticate user"); - } else { - queryClient.invalidate(); - const s = await session.update(); - if (s?.user) { - posthog?.identify(s.user.id, { - email: s.user.email, - name: s.user.name, - }); - } - posthog?.capture("login", { - method: "verification-code", - }); - router.push(callbackUrl); - } - }} - onChange={() => setEmail(undefined)} - email={getValues("email")} - /> - ); - } - - return ( -
{ - const res = await sendVerificationEmail(email); - - if (res?.error) { - setError("email", { - message: t("userNotFound"), - }); - } else { - setEmail(email); - } - })} - > -
{t("login")}
-

- {t("stepSummary", { - current: 1, - total: 2, - })} -

-
- - - {formState.errors.email?.message ? ( -
- {formState.errors.email.message} -
- ) : null} -
-
- - -
-
- ); -}; diff --git a/apps/web/src/components/auth/auth-layout.tsx b/apps/web/src/components/auth/auth-layout.tsx index 2b1ef8ba1..17c9d44e8 100644 --- a/apps/web/src/components/auth/auth-layout.tsx +++ b/apps/web/src/components/auth/auth-layout.tsx @@ -1,31 +1,22 @@ -import Link from "next/link"; -import { Trans } from "next-i18next"; import React from "react"; import { Logo } from "@/components/logo"; -import { IfCloudHosted } from "@/contexts/environment"; -export const AuthLayout = ({ children }: { children?: React.ReactNode }) => { +export const AuthCard = ({ children }: { children?: React.ReactNode }) => { return ( -
-
-
-
- -
-
{children}
-
- -

- - - -

-
+
+
+
+
{children}
+
+ ); +}; + +export const AuthFooter = ({ children }: { children?: React.ReactNode }) => { + return ( +
+ {children}
); }; diff --git a/apps/web/src/utils/auth.ts b/apps/web/src/utils/auth.ts index fb40c4569..22a4df0fb 100644 --- a/apps/web/src/utils/auth.ts +++ b/apps/web/src/utils/auth.ts @@ -16,11 +16,121 @@ import NextAuth, { } from "next-auth/next"; import CredentialsProvider from "next-auth/providers/credentials"; import EmailProvider from "next-auth/providers/email"; +import { Provider } from "next-auth/providers/index"; import { absoluteUrl } from "@/utils/absolute-url"; import { mergeGuestsIntoUser } from "@/utils/auth/merge-user"; +import { isOIDCEnabled, oidcName } from "@/utils/constants"; import { emailClient } from "@/utils/emails"; +const providers: Provider[] = [ + // When a user registers, we don't want to go through the email verification process + // so this providers 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( + 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, + }, + }); + + 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, + }, + }); + + if (user) { + await emailClient.sendTemplate("LoginEmail", { + to: email, + subject: `${token} is your 6-digit code`, + props: { + name: user.name, + magicLink: absoluteUrl("/auth/login", { + magicLink: url, + }), + code: token, + }, + }); + } + }, + }), +]; + +// If we have an OAuth provider configured, we add it to the list of providers +if (isOIDCEnabled) { + providers.push({ + id: "oidc", + name: oidcName, + 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: profile.name, + email: profile.email, + }; + }, + }); +} + const getAuthOptions = (...args: GetServerSessionParams) => ({ adapter: PrismaAdapter(prisma), @@ -28,90 +138,7 @@ const getAuthOptions = (...args: GetServerSessionParams) => session: { strategy: "jwt", }, - providers: [ - // When a user registers, we don't want to go through the email verification process - // so this providers 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( - 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, - }, - }); - - 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, - }, - }); - - if (user) { - await emailClient.sendTemplate("LoginEmail", { - to: email, - subject: `${token} is your 6-digit code`, - props: { - name: user.name, - magicLink: absoluteUrl("/auth/login", { - magicLink: url, - }), - code: token, - }, - }); - } - }, - }), - ], + providers: providers, pages: { signIn: "/login", signOut: "/logout", @@ -119,25 +146,30 @@ const getAuthOptions = (...args: GetServerSessionParams) => }, callbacks: { async signIn({ user, email }) { - if (email?.verificationRequest) { - if (user.email) { - const userExists = - (await prisma.user.count({ - where: { - email: user.email, - }, - })) > 0; - - if (userExists) { - if (isEmailBlocked(user.email)) { - return false; - } - return true; - } else { - return false; - } + // Make sure email is allowed + if (user.email) { + const isBlocked = isEmailBlocked(user.email); + if (isBlocked) { + return false; } - } else if (user.email) { + } + + // 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) { diff --git a/apps/web/src/utils/constants.ts b/apps/web/src/utils/constants.ts index ad748850a..7af61492c 100644 --- a/apps/web/src/utils/constants.ts +++ b/apps/web/src/utils/constants.ts @@ -12,3 +12,7 @@ export const monthlyPriceUsd = 7; export const annualPriceUsd = 42; export const appVersion = process.env.NEXT_PUBLIC_APP_VERSION; + +export const isOIDCEnabled = process.env.OIDC_ENABLED === "true"; + +export const oidcName = process.env.OIDC_NAME ?? "OpenID Connect"; diff --git a/packages/database/prisma/migrations/20231117153753_add_nextauth_provider_accounts/migration.sql b/packages/database/prisma/migrations/20231117153753_add_nextauth_provider_accounts/migration.sql new file mode 100644 index 000000000..5c12ba64c --- /dev/null +++ b/packages/database/prisma/migrations/20231117153753_add_nextauth_provider_accounts/migration.sql @@ -0,0 +1,20 @@ +-- CreateTable +CREATE TABLE "Account" ( + "id" TEXT NOT NULL, + "userId" TEXT 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") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId"); diff --git a/packages/database/prisma/migrations/20231118134458_add_account_user_index/migration.sql b/packages/database/prisma/migrations/20231118134458_add_account_user_index/migration.sql new file mode 100644 index 000000000..e0a97c694 --- /dev/null +++ b/packages/database/prisma/migrations/20231118134458_add_account_user_index/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX "Account_userId_idx" ON "Account" USING HASH ("userId"); diff --git a/packages/database/prisma/migrations/20231122061137_map_account_table_names/migration.sql b/packages/database/prisma/migrations/20231122061137_map_account_table_names/migration.sql new file mode 100644 index 000000000..0f2591773 --- /dev/null +++ b/packages/database/prisma/migrations/20231122061137_map_account_table_names/migration.sql @@ -0,0 +1,32 @@ +/* + Warnings: + + - You are about to drop the `Account` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropTable +DROP TABLE "Account"; + +-- CreateTable +CREATE TABLE "accounts" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "provider_account_id" TEXT NOT NULL, + "refresh_token" TEXT, + "access_token" TEXT, + "expires_at" INTEGER, + "token_type" TEXT, + "scope" TEXT, + "id_token" TEXT, + "session_state" TEXT, + + CONSTRAINT "accounts_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "accounts_user_id_idx" ON "accounts" USING HASH ("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "accounts_provider_provider_account_id_key" ON "accounts"("provider", "provider_account_id"); diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 37460fab2..e1cb017f6 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -16,6 +16,27 @@ enum TimeFormat { @@map("time_format") } +model Account { + id String @id @default(cuid()) + userId String @map("user_id") + type String + provider String + providerAccountId String @map("provider_account_id") + 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]) + + @@unique([provider, providerAccountId]) + @@index([userId], type: Hash) + @@map("accounts") +} + model User { id String @id @default(cuid()) name String @@ -35,6 +56,7 @@ model User { watcher Watcher[] events Event[] subscription Subscription? @relation(fields: [subscriptionId], references: [id]) + accounts Account[] @@map("users") } diff --git a/turbo.json b/turbo.json index 56c47a8a3..36b2783e8 100644 --- a/turbo.json +++ b/turbo.json @@ -85,6 +85,11 @@ "NEXT_PUBLIC_VERCEL_URL", "NODE_ENV", "NOREPLY_EMAIL", + "OIDC_ENABLED", + "OIDC_NAME", + "OIDC_DISCOVERY_URL", + "OIDC_CLIENT_ID", + "OIDC_CLIENT_SECRET", "PADDLE_PUBLIC_KEY", "PORT", "SECRET_PASSWORD",