Add support for OpenID Connect (#939)

This commit is contained in:
Armand Didierjean 2023-11-26 05:13:42 +01:00 committed by GitHub
parent 9ceb27f6e3
commit 7c03059bc0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 562 additions and 305 deletions

View file

@ -68,3 +68,49 @@ These variables need to be configured to let Rallly send out transactional email
<ParamField path="SMTP_TLS_ENABLED" default={"false"}> <ParamField path="SMTP_TLS_ENABLED" default={"false"}>
Enable TLS for your SMTP connection Enable TLS for your SMTP connection
</ParamField> </ParamField>
### 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.
<ParamField path="OIDC_ENABLED">
Must be set to `true` to enable OIDC Login
</ParamField>
<ParamField path="OIDC_NAME" default="OpenID Connect">
The user-facing name of your provider as it will be shown on the login page
</ParamField>
<ParamField path="OIDC_DISCOVERY_URL">
URL of the `.well-known/openid-configuration` endpoint for your OIDC provider
</ParamField>
<ParamField path="OIDC_CLIENT_ID">
The client ID of your OIDC application
</ParamField>
<ParamField path="OIDC_CLIENT_SECRET">
The client secret of your OIDC application
</ParamField>
#### 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
```
<Info>
Replace `{NEXT_PUBLIC_BASE_URL}` with the base URL of your Rallly instance.
</Info>
Ensure this URL is added to the list of allowed redirect URIs in your OIDC provider's application settings.

View file

@ -68,6 +68,49 @@ declare global {
* Determines what email provider to use. "smtp" or "ses" * Determines what email provider to use. "smtp" or "ses"
*/ */
EMAIL_PROVIDER?: "smtp" | "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 * AWS access key ID
*/ */

View file

@ -64,6 +64,26 @@ declare global {
* Determines what email provider to use. "smtp" or "ses" * Determines what email provider to use. "smtp" or "ses"
*/ */
EMAIL_PROVIDER?: "smtp" | "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 * AWS access key ID
*/ */

View file

@ -40,6 +40,7 @@
"location": "Location", "location": "Location",
"locationPlaceholder": "Joe's Coffee Shop", "locationPlaceholder": "Joe's Coffee Shop",
"login": "Login", "login": "Login",
"loginWith": "Login with {provider}",
"logout": "Logout", "logout": "Logout",
"manage": "Manage", "manage": "Manage",
"mixedOptionsDescription": "You can't have both time and date options in the same poll. Which would you like to keep?", "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", "continueAs": "Continue as",
"finalizeFeature": "Finalize", "finalizeFeature": "Finalize",
"duplicateFeature": "Duplicate", "duplicateFeature": "Duplicate",
"pageMovedDescription": "Redirecting to <a>{newUrl}</a>" "pageMovedDescription": "Redirecting to <a>{newUrl}</a>",
"notRegistered": "Don't have an account? <a>Register</a>"
} }

View file

@ -1,7 +1,7 @@
"use client";
import { AuthLayout } from "@/components/auth/auth-layout";
export default function Layout({ children }: { children: React.ReactNode }) { export default function Layout({ children }: { children: React.ReactNode }) {
return <AuthLayout>{children}</AuthLayout>; return (
<div className="h-full p-3 sm:p-8">
<div className="mx-auto max-w-lg">{children}</div>
</div>
);
} }

View file

@ -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<string>();
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 (
<VerifyCode
onSubmit={async (code) => {
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 (
<form
onSubmit={handleSubmit(async ({ email }) => {
const res = await sendVerificationEmail(email);
if (res?.error) {
setError("email", {
message: t("userNotFound"),
});
} else {
setEmail(email);
}
})}
>
<div className="mb-1 text-2xl font-bold">{t("login")}</div>
<p className="mb-4 text-gray-500">
{t("stepSummary", {
current: 1,
total: 2,
})}
</p>
<fieldset className="mb-4">
<label htmlFor="email" className="mb-1 text-gray-500">
{t("email")}
</label>
<TextInput
className="w-full"
id="email"
proportions="lg"
autoFocus={true}
error={!!formState.errors.email}
disabled={formState.isSubmitting}
placeholder={t("emailPlaceholder")}
{...register("email", { validate: validEmail })}
/>
{formState.errors.email?.message ? (
<div className="mt-2 text-sm text-rose-500">
{formState.errors.email.message}
</div>
) : null}
</fieldset>
<div className="flex flex-col gap-2">
<Button
loading={formState.isSubmitting}
type="submit"
size="lg"
variant="primary"
className=""
>
{t("continue")}
</Button>
{hasAlternativeLoginMethods ? (
<>
<hr className="border-t border-grey-500 my-4" />
<div className="grid gap-4">
<IfCloudHosted>
<Button size="lg" asChild>
<Link href="/">
<UserIcon className="w-4 h-4" />
<Trans i18nKey="continueAsGuest" />
</Link>
</Button>
</IfCloudHosted>
{hasOIDCProvider ? (
<Button
icon={LogInIcon}
size="lg"
onClick={() => signIn("oidc")}
>
<Trans
i18nKey="loginWith"
values={{ provider: oidcConfig.name }}
/>
</Button>
) : null}
</div>
</>
) : null}
</div>
</form>
);
}

View file

@ -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 { Params } from "@/app/[locale]/types";
import { getTranslation } from "@/app/i18n"; 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() { export default async function LoginPage({ params }: { params: Params }) {
return <LoginForm />; const { t } = await getTranslation(params.locale);
return (
<div>
<AuthCard>
<LoginForm
oidcConfig={isOIDCEnabled ? { name: oidcName } : undefined}
/>
</AuthCard>
<div className="mt-4 text-center pt-4 text-gray-500 sm:text-base">
<Trans
t={t}
i18nKey="notRegistered"
defaults="Don't have an account? <a>Register</a>"
components={{
a: <Link href="/register" className="text-link" />,
}}
/>
</div>
</div>
);
} }
export async function generateMetadata({ params }: { params: Params }) { export async function generateMetadata({ params }: { params: Params }) {

View file

@ -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 { RegisterForm } from "@/app/[locale]/(auth)/register/register-page";
import { Params } from "@/app/[locale]/types"; import { Params } from "@/app/[locale]/types";
import { getTranslation } from "@/app/i18n"; import { getTranslation } from "@/app/i18n";
import { AuthCard } from "@/components/auth/auth-layout";
export default function Page() { export default async function Page({ params }: { params: Params }) {
return <RegisterForm />; const { t } = await getTranslation(params.locale);
return (
<div>
<AuthCard>
<RegisterForm />
</AuthCard>
<div className="mt-4 text-center pt-4 text-gray-500 sm:text-base">
<Trans
t={t}
i18nKey="alreadyRegistered"
components={{
a: <Link href="/login" className="text-link" />,
}}
/>
</div>
</div>
);
} }
export async function generateMetadata({ params }: { params: Params }) { export async function generateMetadata({ params }: { params: Params }) {

View file

@ -1,14 +1,13 @@
"use client"; "use client";
import { Button } from "@rallly/ui/button"; import { Button } from "@rallly/ui/button";
import Link from "next/link";
import { useParams, useSearchParams } from "next/navigation"; import { useParams, useSearchParams } from "next/navigation";
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
import { Trans, useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { usePostHog } from "posthog-js/react"; import { usePostHog } from "posthog-js/react";
import React from "react"; import React from "react";
import { useForm } from "react-hook-form"; 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 { TextInput } from "@/components/text-input";
import { useDayjs } from "@/utils/dayjs"; import { useDayjs } from "@/utils/dayjs";
import { requiredString, validEmail } from "@/utils/form-validation"; import { requiredString, validEmail } from "@/utils/form-validation";
@ -19,17 +18,14 @@ type RegisterFormData = {
email: string; email: string;
}; };
export const RegisterForm: React.FunctionComponent<{ export const RegisterForm = () => {
onClickLogin?: React.MouseEventHandler;
}> = ({ onClickLogin }) => {
const [defaultEmail, setDefaultEmail] = useDefaultEmail();
const { t } = useTranslation(); const { t } = useTranslation();
const { timeZone } = useDayjs(); const { timeZone } = useDayjs();
const params = useParams<{ locale: string }>(); const params = useParams<{ locale: string }>();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { register, handleSubmit, getValues, setError, formState } = const { register, handleSubmit, getValues, setError, formState } =
useForm<RegisterFormData>({ useForm<RegisterFormData>({
defaultValues: { email: defaultEmail }, defaultValues: { email: "" },
}); });
const queryClient = trpc.useUtils(); const queryClient = trpc.useUtils();
@ -71,7 +67,6 @@ export const RegisterForm: React.FunctionComponent<{
callbackUrl: searchParams?.get("callbackUrl") ?? undefined, callbackUrl: searchParams?.get("callbackUrl") ?? undefined,
}); });
}} }}
onChange={() => setToken(undefined)}
email={getValues("email")} email={getValues("email")}
/> />
); );
@ -156,24 +151,6 @@ export const RegisterForm: React.FunctionComponent<{
> >
{t("continue")} {t("continue")}
</Button> </Button>
<div className="mt-4 border-t pt-4 text-gray-500 sm:text-base">
<Trans
t={t}
i18nKey="alreadyRegistered"
components={{
a: (
<Link
href="/login"
className="text-link"
onClick={(e) => {
setDefaultEmail(getValues("email"));
onClickLogin?.(e);
}}
/>
),
}}
/>
</div>
</form> </form>
); );
}; };

View file

@ -1,22 +1,13 @@
"use client"; "use client";
import { Button } from "@rallly/ui/button"; 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 { Trans, useTranslation } from "next-i18next";
import React from "react"; import React from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { createGlobalState } from "react-use";
import { usePostHog } from "@/utils/posthog"; import { requiredString } from "../../utils/form-validation";
import { trpc } from "@/utils/trpc/client";
import { requiredString, validEmail } from "../../utils/form-validation";
import { TextInput } from "../text-input"; import { TextInput } from "../text-input";
export const useDefaultEmail = createGlobalState(""); export const verifyCode = async (options: { email: string; token: string }) => {
const verifyCode = async (options: { email: string; token: string }) => {
const url = `${ const url = `${
window.location.origin window.location.origin
}/api/auth/callback/email?email=${encodeURIComponent(options.email)}&token=${ }/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<{ export const VerifyCode: React.FunctionComponent<{
email: string; email: string;
onSubmit: (code: string) => Promise<void>; onSubmit: (code: string) => Promise<void>;
onChange: () => void; }> = ({ onSubmit, email }) => {
}> = ({ onChange, onSubmit, email }) => {
const { register, handleSubmit, setError, formState } = useForm<{ const { register, handleSubmit, setError, formState } = useForm<{
code: string; code: string;
}>(); }>();
@ -73,7 +63,6 @@ export const VerifyCode: React.FunctionComponent<{
href="#" href="#"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
onChange();
}} }}
/> />
), ),
@ -113,132 +102,3 @@ export const VerifyCode: React.FunctionComponent<{
</div> </div>
); );
}; };
export const LoginForm: React.FunctionComponent<{
onClickRegister?: (
e: React.MouseEvent<HTMLAnchorElement>,
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<string>();
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 (
<VerifyCode
onSubmit={async (code) => {
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 (
<form
onSubmit={handleSubmit(async ({ email }) => {
const res = await sendVerificationEmail(email);
if (res?.error) {
setError("email", {
message: t("userNotFound"),
});
} else {
setEmail(email);
}
})}
>
<div className="mb-1 text-2xl font-bold">{t("login")}</div>
<p className="mb-4 text-gray-500">
{t("stepSummary", {
current: 1,
total: 2,
})}
</p>
<fieldset className="mb-4">
<label htmlFor="email" className="mb-1 text-gray-500">
{t("email")}
</label>
<TextInput
className="w-full"
id="email"
proportions="lg"
autoFocus={true}
error={!!formState.errors.email}
disabled={formState.isSubmitting}
placeholder={t("emailPlaceholder")}
{...register("email", { validate: validEmail })}
/>
{formState.errors.email?.message ? (
<div className="mt-2 text-sm text-rose-500">
{formState.errors.email.message}
</div>
) : null}
</fieldset>
<div className="flex flex-col gap-2">
<Button
loading={formState.isSubmitting}
type="submit"
size="lg"
variant="primary"
className=""
>
{t("continue")}
</Button>
<Button size="lg" asChild>
<Link
href="/register"
onClick={(e) => {
React.startTransition(() => {
setDefaultEmail(getValues("email"));
onClickRegister?.(e, getValues("email"));
});
}}
>
{t("createAnAccount")}
</Link>
</Button>
</div>
</form>
);
};

View file

@ -1,31 +1,22 @@
import Link from "next/link";
import { Trans } from "next-i18next";
import React from "react"; import React from "react";
import { Logo } from "@/components/logo"; 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 ( return (
<div className="h-full p-3 sm:p-8"> <div className="overflow-hidden rounded-lg border bg-white shadow-sm">
<div className="mx-auto max-w-lg"> <div className="bg-pattern border-t-primary-600 flex justify-center border-b border-t-4 bg-gray-500/5 p-4 text-center sm:p-8">
<div className="overflow-hidden rounded-lg border bg-white shadow-sm"> <Logo />
<div className="bg-pattern border-t-primary-600 flex justify-center border-b border-t-4 bg-gray-500/5 p-4 text-center sm:p-8">
<Logo />
</div>
<div className="p-4 sm:p-6">{children}</div>
</div>
<IfCloudHosted>
<p className="mt-8 text-center">
<Link
href="/polls"
className="text-muted-foreground hover:underline"
>
<Trans i18nKey="continueAsGuest" defaults="Continue as Guest" />
</Link>
</p>
</IfCloudHosted>
</div> </div>
<div className="p-4 sm:p-6">{children}</div>
</div>
);
};
export const AuthFooter = ({ children }: { children?: React.ReactNode }) => {
return (
<div className="flex flex-col gap-2 text-gray-500 text-sm mt-4">
{children}
</div> </div>
); );
}; };

View file

@ -16,11 +16,121 @@ import NextAuth, {
} from "next-auth/next"; } from "next-auth/next";
import CredentialsProvider from "next-auth/providers/credentials"; import CredentialsProvider from "next-auth/providers/credentials";
import EmailProvider from "next-auth/providers/email"; import EmailProvider from "next-auth/providers/email";
import { Provider } from "next-auth/providers/index";
import { absoluteUrl } from "@/utils/absolute-url"; import { absoluteUrl } from "@/utils/absolute-url";
import { mergeGuestsIntoUser } from "@/utils/auth/merge-user"; import { mergeGuestsIntoUser } from "@/utils/auth/merge-user";
import { isOIDCEnabled, oidcName } from "@/utils/constants";
import { emailClient } from "@/utils/emails"; 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<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,
},
});
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) => const getAuthOptions = (...args: GetServerSessionParams) =>
({ ({
adapter: PrismaAdapter(prisma), adapter: PrismaAdapter(prisma),
@ -28,90 +138,7 @@ const getAuthOptions = (...args: GetServerSessionParams) =>
session: { session: {
strategy: "jwt", strategy: "jwt",
}, },
providers: [ providers: 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<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,
},
});
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,
},
});
}
},
}),
],
pages: { pages: {
signIn: "/login", signIn: "/login",
signOut: "/logout", signOut: "/logout",
@ -119,25 +146,30 @@ const getAuthOptions = (...args: GetServerSessionParams) =>
}, },
callbacks: { callbacks: {
async signIn({ user, email }) { async signIn({ user, email }) {
if (email?.verificationRequest) { // Make sure email is allowed
if (user.email) { if (user.email) {
const userExists = const isBlocked = isEmailBlocked(user.email);
(await prisma.user.count({ if (isBlocked) {
where: { return false;
email: user.email,
},
})) > 0;
if (userExists) {
if (isEmailBlocked(user.email)) {
return false;
}
return true;
} else {
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 // merge guest user into newly logged in user
const session = await getServerSession(...args); const session = await getServerSession(...args);
if (session && session.user.email === null) { if (session && session.user.email === null) {

View file

@ -12,3 +12,7 @@ export const monthlyPriceUsd = 7;
export const annualPriceUsd = 42; export const annualPriceUsd = 42;
export const appVersion = process.env.NEXT_PUBLIC_APP_VERSION; 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";

View file

@ -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");

View file

@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "Account_userId_idx" ON "Account" USING HASH ("userId");

View file

@ -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");

View file

@ -16,6 +16,27 @@ enum TimeFormat {
@@map("time_format") @@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 { model User {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
@ -35,6 +56,7 @@ model User {
watcher Watcher[] watcher Watcher[]
events Event[] events Event[]
subscription Subscription? @relation(fields: [subscriptionId], references: [id]) subscription Subscription? @relation(fields: [subscriptionId], references: [id])
accounts Account[]
@@map("users") @@map("users")
} }

View file

@ -85,6 +85,11 @@
"NEXT_PUBLIC_VERCEL_URL", "NEXT_PUBLIC_VERCEL_URL",
"NODE_ENV", "NODE_ENV",
"NOREPLY_EMAIL", "NOREPLY_EMAIL",
"OIDC_ENABLED",
"OIDC_NAME",
"OIDC_DISCOVERY_URL",
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"PADDLE_PUBLIC_KEY", "PADDLE_PUBLIC_KEY",
"PORT", "PORT",
"SECRET_PASSWORD", "SECRET_PASSWORD",