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"}>
Enable TLS for your SMTP connection
</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"
*/
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
*/

View file

@ -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
*/

View file

@ -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 <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 }) {
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 { 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 <LoginForm />;
export default async function LoginPage({ params }: { params: Params }) {
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 }) {

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 { Params } from "@/app/[locale]/types";
import { getTranslation } from "@/app/i18n";
import { AuthCard } from "@/components/auth/auth-layout";
export default function Page() {
return <RegisterForm />;
export default async function Page({ params }: { params: Params }) {
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 }) {

View file

@ -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<RegisterFormData>({
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")}
</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>
);
};

View file

@ -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<void>;
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<{
</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 { Logo } from "@/components/logo";
import { IfCloudHosted } from "@/contexts/environment";
export const AuthLayout = ({ children }: { children?: React.ReactNode }) => {
export const AuthCard = ({ children }: { children?: React.ReactNode }) => {
return (
<div className="h-full p-3 sm:p-8">
<div className="mx-auto max-w-lg">
<div className="overflow-hidden rounded-lg border bg-white shadow-sm">
<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>
);
};
export const AuthFooter = ({ children }: { children?: React.ReactNode }) => {
return (
<div className="flex flex-col gap-2 text-gray-500 text-sm mt-4">
{children}
</div>
);
};

View file

@ -16,19 +16,14 @@ 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 getAuthOptions = (...args: GetServerSessionParams) =>
({
adapter: PrismaAdapter(prisma),
secret: process.env.SECRET_PASSWORD,
session: {
strategy: "jwt",
},
providers: [
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({
@ -111,7 +106,39 @@ const getAuthOptions = (...args: GetServerSessionParams) =>
}
},
}),
],
];
// 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),
secret: process.env.SECRET_PASSWORD,
session: {
strategy: "jwt",
},
providers: providers,
pages: {
signIn: "/login",
signOut: "/logout",
@ -119,25 +146,30 @@ const getAuthOptions = (...args: GetServerSessionParams) =>
},
callbacks: {
async signIn({ user, email }) {
if (email?.verificationRequest) {
// Make sure email is allowed
if (user.email) {
const userExists =
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,
email: user.email as string,
},
})) > 0;
})) === 0;
if (userExists) {
if (isEmailBlocked(user.email)) {
if (isUnregisteredUser) {
return false;
}
return true;
} else {
return false;
}
}
} else if (user.email) {
// merge guest user into newly logged in user
const session = await getServerSession(...args);
if (session && session.user.email === null) {

View file

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

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")
}
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")
}

View file

@ -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",