♻️ Upgrade to next-auth v5 (#1558)

This commit is contained in:
Luke Vella 2025-02-12 12:51:04 +07:00 committed by GitHub
parent 17d386d905
commit 4b26dc50b3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 765 additions and 628 deletions

View file

@ -5,9 +5,6 @@ SECRET_PASSWORD=abcdef1234567890abcdef1234567890
# Example: https://example.com
NEXT_PUBLIC_BASE_URL=http://localhost:3000
# NEXTAUTH_URL should be the same as NEXT_PUBLIC_BASE_URL
NEXTAUTH_URL=http://localhost:3000
# A connection string to your Postgres database
DATABASE_URL="postgres://postgres:postgres@localhost:5450/rallly"

View file

@ -1,6 +1,5 @@
PORT=3002
NEXT_PUBLIC_BASE_URL=http://localhost:3002
NEXTAUTH_URL=$NEXT_PUBLIC_BASE_URL
SECRET_PASSWORD=abcdef1234567890abcdef1234567890
DATABASE_URL=postgres://postgres:postgres@localhost:5450/rallly
SUPPORT_EMAIL=support@rallly.co

View file

@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import type { TimeFormat } from "@rallly/database";
import type { NextRequest } from "next/server";
import type { DefaultSession, DefaultUser } from "next-auth";
import NextAuth from "next-auth";
import type { DefaultJWT } from "next-auth/jwt";
@ -25,6 +26,10 @@ declare module "next-auth" {
timeFormat?: TimeFormat | null;
weekStart?: number | null;
}
interface NextAuthRequest extends NextRequest {
auth: Session | null;
}
}
declare module "next-auth/jwt" {

View file

@ -1,7 +1,7 @@
const typescriptTransform = require("i18next-scanner-typescript");
module.exports = {
input: ["src/**/*.{ts,tsx}", "!src/auth.ts"],
input: ["src/**/*.{ts,tsx}", "!src/next-auth*.ts"],
options: {
nsSeparator: false,
defaultNs: "app",

View file

@ -18,11 +18,12 @@
"docker:start": "./scripts/docker-start.sh"
},
"dependencies": {
"@auth/prisma-adapter": "^1.0.3",
"@auth/prisma-adapter": "^2.7.4",
"@aws-sdk/client-s3": "^3.645.0",
"@aws-sdk/s3-request-presigner": "^3.645.0",
"@hookform/resolvers": "^3.3.1",
"@next/bundle-analyzer": "^12.3.4",
"@panva/hkdf": "^1.2.1",
"@radix-ui/react-slot": "^1.0.1",
"@radix-ui/react-switch": "^1.0.2",
"@rallly/billing": "*",
@ -59,6 +60,7 @@
"ics": "^3.1.0",
"intl-messageformat": "^10.3.4",
"iron-session": "^6.3.1",
"jose": "^5.9.6",
"js-cookie": "^3.0.1",
"linkify-react": "^4.1.3",
"linkifyjs": "^4.1.3",
@ -66,7 +68,7 @@
"lucide-react": "^0.387.0",
"micro": "^10.0.1",
"nanoid": "^5.0.9",
"next-auth": "^4.24.5",
"next-auth": "^5.0.0-beta.25",
"next-i18next": "^13.0.3",
"php-serialize": "^4.1.1",
"postcss": "^8.4.31",

View file

@ -303,5 +303,6 @@
"loginVerifyDescription": "Check your email for the verification code",
"createAccount": "Create Account",
"tooManyRequests": "Too many requests",
"tooManyRequestsDescription": "Please try again later."
"tooManyRequestsDescription": "Please try again later.",
"loginMagicLinkError": "This link is invalid or expired. Please request a new link."
}

View file

@ -38,7 +38,7 @@ export function DeleteAccountDialog({
onSuccess() {
posthog?.capture("delete account");
signOut({
callbackUrl: "/login",
redirectTo: "/login",
});
},
});

View file

@ -4,10 +4,12 @@ import { Button } from "@rallly/ui/button";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import React from "react";
import { OptimizedAvatarImage } from "@/components/optimized-avatar-image";
import { Skeleton } from "@/components/skeleton";
import { Trans } from "@/components/trans";
import { useTranslation } from "@/i18n/client";
import { trpc } from "@/trpc/client";
type PageProps = { magicLink: string; email: string };
@ -15,6 +17,9 @@ type PageProps = { magicLink: string; email: string };
export const LoginPage = ({ magicLink, email }: PageProps) => {
const session = useSession();
const posthog = usePostHog();
const { t } = useTranslation();
const [error, setError] = React.useState<string | null>(null);
const magicLinkFetch = useMutation({
mutationFn: async () => {
const res = await fetch(magicLink);
@ -31,9 +36,15 @@ export const LoginPage = ({ magicLink, email }: PageProps) => {
name: updatedSession.user.name,
});
}
router.push(data.url);
} else {
setError(
t("loginMagicLinkError", {
defaultValue:
"This link is invalid or expired. Please request a new link.",
}),
);
}
router.push(data.url);
},
});
const { data } = trpc.user.getByEmail.useQuery({ email });
@ -72,6 +83,7 @@ export const LoginPage = ({ magicLink, email }: PageProps) => {
<Trans i18nKey="login" defaults="Login" />
</Button>
</div>
{error && <p className="text-destructive text-sm">{error}</p>}
</div>
</div>
);

View file

@ -4,18 +4,24 @@ import { prisma } from "@rallly/database";
import { cookies } from "next/headers";
export async function setVerificationEmail(email: string) {
const count = await prisma.user.count({
const user = await prisma.user.findUnique({
where: {
email,
},
select: {
email: true,
},
});
cookies().set("verification-email", email, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 15 * 60,
});
if (user) {
cookies().set("verification-email", user.email, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 15 * 60,
});
return true;
}
return count > 0;
return false;
}

View file

@ -53,13 +53,13 @@ export function LoginWithEmailForm() {
if (doesExist) {
await signIn("email", {
email: identifier,
callbackUrl: searchParams?.get("callbackUrl") ?? undefined,
redirectTo: searchParams?.get("redirectTo") ?? undefined,
redirect: false,
});
// redirect to verify page with callbackUrl
// redirect to verify page with redirectTo
router.push(
`/login/verify?callbackUrl=${encodeURIComponent(
searchParams?.get("callbackUrl") ?? "",
`/login/verify?redirectTo=${encodeURIComponent(
searchParams?.get("redirectTo") ?? "",
)}`,
);
} else {

View file

@ -6,16 +6,16 @@ import { Trans } from "@/components/trans";
export async function LoginWithOIDC({
name,
callbackUrl,
redirectTo,
}: {
name: string;
callbackUrl?: string;
redirectTo?: string;
}) {
return (
<Button
onClick={() => {
signIn("oidc", {
callbackUrl,
redirectTo,
});
}}
variant="link"

View file

@ -15,7 +15,7 @@ function SSOImage({ provider }: { provider: string }) {
);
}
if (provider === "azure-ad") {
if (provider === "microsoft-entra-id") {
return (
<Image
src="/static/microsoft.svg"
@ -40,11 +40,11 @@ function SSOImage({ provider }: { provider: string }) {
export function SSOProvider({
providerId,
name,
callbackUrl,
redirectTo,
}: {
providerId: string;
name: string;
callbackUrl?: string;
redirectTo?: string;
}) {
const { t } = useTranslation();
return (
@ -58,7 +58,7 @@ export function SSOProvider({
key={providerId}
onClick={() => {
signIn(providerId, {
callbackUrl,
redirectTo,
});
}}
>

View file

@ -1,7 +1,9 @@
import Link from "next/link";
import { Trans } from "react-i18next/TransWithoutContext";
import { getOAuthProviders } from "@/auth";
import { GoogleProvider } from "@/auth/providers/google";
import { MicrosoftProvider } from "@/auth/providers/microsoft";
import { OIDCProvider } from "@/auth/providers/oidc";
import { getTranslation } from "@/i18n/server";
import {
@ -22,20 +24,14 @@ export default async function LoginPage({
searchParams,
}: {
searchParams?: {
callbackUrl?: string;
redirectTo?: string;
};
}) {
const { t } = await getTranslation();
const oAuthProviders = getOAuthProviders();
const hasAlternateLoginMethods = oAuthProviders.length > 0;
const oidcProvider = oAuthProviders.find(
(provider) => provider.id === "oidc",
);
const socialProviders = oAuthProviders.filter(
(provider) => provider.id !== "oidc",
);
const oidcProvider = OIDCProvider();
const socialProviders = [GoogleProvider(), MicrosoftProvider()];
const hasAlternateLoginMethods = socialProviders.length > 0 || !!oidcProvider;
return (
<AuthPageContainer>
@ -58,19 +54,21 @@ export default async function LoginPage({
{oidcProvider ? (
<LoginWithOIDC
name={oidcProvider.name}
callbackUrl={searchParams?.callbackUrl}
redirectTo={searchParams?.redirectTo}
/>
) : null}
{socialProviders ? (
<div className="grid gap-4">
{socialProviders.map((provider) => (
<SSOProvider
key={provider.id}
providerId={provider.id}
name={provider.name}
callbackUrl={searchParams?.callbackUrl}
/>
))}
{socialProviders.map((provider) =>
provider ? (
<SSOProvider
key={provider.id}
providerId={provider.id}
name={provider.options?.name || provider.name}
redirectTo={searchParams?.redirectTo}
/>
) : null,
)}
</div>
) : null}
</AuthPageContent>

View file

@ -50,7 +50,7 @@ export function OTPForm({ email }: { email: string }) {
message: t("wrongVerificationCode"),
});
} else {
window.location.href = searchParams?.get("callbackUrl") ?? "/";
window.location.href = searchParams?.get("redirectTo") ?? "/";
}
});

View file

@ -68,7 +68,7 @@ export function OTPForm({ token }: { token: string }) {
signIn("registration-token", {
token,
callbackUrl: searchParams?.get("callbackUrl") ?? "/",
redirectTo: searchParams?.get("redirectTo") ?? "/",
});
});

View file

@ -4,12 +4,12 @@ import "../../style.css";
import { Toaster } from "@rallly/ui/toaster";
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import { SessionProvider } from "next-auth/react";
import React from "react";
import { TimeZoneChangeDetector } from "@/app/[locale]/timezone-change-detector";
import { Providers } from "@/app/providers";
import { getServerSession } from "@/auth";
import { SessionProvider } from "@/auth/session-provider";
import { auth } from "@/next-auth";
const inter = Inter({
subsets: ["latin"],
@ -30,7 +30,7 @@ export default async function Root({
children: React.ReactNode;
params: { locale: string };
}) {
const session = await getServerSession();
const session = await auth();
return (
<html lang={locale} className={inter.className}>

View file

@ -0,0 +1,6 @@
import { withPosthog } from "@rallly/posthog/server";
import { handlers } from "@/next-auth";
export const GET = withPosthog(handlers.GET);
export const POST = withPosthog(handlers.POST);

View file

@ -3,7 +3,7 @@ import { cookies } from "next/headers";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getServerSession } from "@/auth";
import { auth } from "@/next-auth";
import type { DisableNotificationsPayload } from "@/trpc/types";
import { decryptToken } from "@/utils/session";
@ -14,7 +14,7 @@ export const GET = async (req: NextRequest) => {
return NextResponse.redirect(new URL("/login", req.url));
}
const session = await getServerSession();
const session = await auth();
if (!session || !session.user?.email) {
return NextResponse.redirect(new URL("/login", req.url));

View file

@ -5,7 +5,7 @@ import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { z } from "zod";
import { getServerSession } from "@/auth";
import { auth } from "@/next-auth";
const inputSchema = z.object({
period: z.enum(["monthly", "yearly"]).optional(),
@ -14,7 +14,7 @@ const inputSchema = z.object({
});
export async function POST(request: NextRequest) {
const userSession = await getServerSession();
const userSession = await auth();
const formData = await request.formData();
const { period = "monthly", return_path } = inputSchema.parse(
Object.fromEntries(formData.entries()),

View file

@ -5,7 +5,7 @@ import * as Sentry from "@sentry/nextjs";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getServerSession } from "@/auth";
import { auth } from "@/next-auth";
export async function GET(request: NextRequest) {
const sessionId = request.nextUrl.searchParams.get("session_id");
@ -32,7 +32,7 @@ export async function GET(request: NextRequest) {
);
}
} else {
const userSession = await getServerSession();
const userSession = await auth();
if (!userSession?.user || userSession.user.email === null) {
Sentry.captureException(new Error("User not logged in"));
return NextResponse.json(

View file

@ -4,7 +4,7 @@ import { ipAddress } from "@vercel/functions";
import type { NextRequest } from "next/server";
import { getLocaleFromHeader } from "@/app/guest";
import { getServerSession } from "@/auth";
import { auth } from "@/next-auth";
import type { TRPCContext } from "@/trpc/context";
import { appRouter } from "@/trpc/routers";
import { getEmailClient } from "@/utils/emails";
@ -15,7 +15,7 @@ const handler = (req: NextRequest) => {
req,
router: appRouter,
createContext: async () => {
const session = await getServerSession();
const session = await auth();
const locale = await getLocaleFromHeader(req);
const user = session?.user
? {

View file

@ -3,7 +3,7 @@ import { cookies } from "next/headers";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getServerSession } from "@/auth";
import { auth } from "@/next-auth";
import { decryptToken } from "@/utils/session";
type EmailChangePayload = {
@ -50,11 +50,11 @@ export const GET = async (request: NextRequest) => {
return NextResponse.json({ error: "No token provided" }, { status: 400 });
}
const session = await getServerSession();
const session = await auth();
if (!session?.user || !session.user.email) {
return NextResponse.redirect(
new URL(`/login?callbackUrl=${request.url}`, request.url),
new URL(`/login?redirectTo=${request.url}`, request.url),
);
}

View file

@ -1,371 +0,0 @@
import { prisma } from "@rallly/database";
import { posthog } from "@rallly/posthog/server";
import { absoluteUrl } from "@rallly/utils/absolute-url";
import { generateOtp, randomid } from "@rallly/utils/nanoid";
import type {
GetServerSidePropsContext,
NextApiRequest,
NextApiResponse,
} from "next";
import type { NextAuthOptions, User } from "next-auth";
import NextAuth, {
getServerSession as getServerSessionWithOptions,
} from "next-auth/next";
import AzureADProvider from "next-auth/providers/azure-ad";
import CredentialsProvider from "next-auth/providers/credentials";
import EmailProvider from "next-auth/providers/email";
import GoogleProvider from "next-auth/providers/google";
import type { Provider } from "next-auth/providers/index";
import { env } from "@/env";
import type { RegistrationTokenPayload } from "@/trpc/types";
import { getEmailClient } from "@/utils/emails";
import { getValueByPath } from "@/utils/get-value-by-path";
import { decryptToken } from "@/utils/session";
import { CustomPrismaAdapter } from "./auth/custom-prisma-adapter";
import { mergeGuestsIntoUser } from "./auth/merge-user";
const providers: Provider[] = [
// When a user registers, we don't want to go through the email verification process
// so this provider 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,
image: 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,
locale: true,
},
});
if (user) {
await getEmailClient(user.locale ?? undefined).sendTemplate(
"LoginEmail",
{
to: email,
props: {
magicLink: absoluteUrl("/auth/login", {
magicLink: url,
}),
code: token,
},
},
);
}
},
}),
];
// If we have an OAuth provider configured, we add it to the list of providers
if (
process.env.OIDC_DISCOVERY_URL &&
process.env.OIDC_CLIENT_ID &&
process.env.OIDC_CLIENT_SECRET
) {
providers.push({
id: "oidc",
name: process.env.OIDC_NAME ?? "OpenID Connect",
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: getValueByPath(profile, env.OIDC_NAME_CLAIM_PATH),
email: getValueByPath(profile, env.OIDC_EMAIL_CLAIM_PATH),
image: getValueByPath(profile, env.OIDC_PICTURE_CLAIM_PATH),
} as User;
},
});
}
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
providers.push(
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
allowDangerousEmailAccountLinking: true,
}),
);
}
if (
process.env.MICROSOFT_TENANT_ID &&
process.env.MICROSOFT_CLIENT_ID &&
process.env.MICROSOFT_CLIENT_SECRET
) {
providers.push(
AzureADProvider({
name: "Microsoft",
tenantId: process.env.MICROSOFT_TENANT_ID,
clientId: process.env.MICROSOFT_CLIENT_ID,
clientSecret: process.env.MICROSOFT_CLIENT_SECRET,
wellKnown:
"https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration",
}),
);
}
const getAuthOptions = (...args: GetServerSessionParams) =>
({
adapter: CustomPrismaAdapter(prisma, {
migrateData: async (userId) => {
const session = await getServerSession(...args);
if (session?.user && session.user.email === null) {
await mergeGuestsIntoUser(userId, [session.user.id]);
}
},
}),
secret: process.env.SECRET_PASSWORD,
session: {
strategy: "jwt",
},
providers: providers,
pages: {
signIn: "/login",
verifyRequest: "/login/verify",
error: "/auth/error",
},
events: {
signIn({ user, account }) {
posthog?.capture({
distinctId: user.id,
event: "login",
properties: {
method: account?.provider,
$set: {
name: user.name,
email: user.email,
timeZone: user.timeZone,
locale: user.locale,
},
},
});
},
},
callbacks: {
async signIn({ user, email, profile }) {
const distinctId = user.id;
// prevent sign in if email is not verified
if (
profile &&
"email_verified" in profile &&
profile.email_verified === false
) {
posthog?.capture({
distinctId,
event: "login failed",
properties: {
reason: "email not verified",
},
});
return false;
}
// Make sure email is allowed
if (user.email) {
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 isRegisteredUser =
(await prisma.user.count({
where: {
email: user.email as string,
},
})) > 0;
return isRegisteredUser;
}
// when we login with a social account for the first time, the user is not created yet
// and the user id will be the same as the provider account id
// we handle this case the the prisma adapter when we link accounts
const isInitialSocialLogin = user.id === profile?.sub;
if (!isInitialSocialLogin) {
// merge guest user into newly logged in user
const session = await getServerSession(...args);
if (session?.user && !session.user.email) {
await mergeGuestsIntoUser(user.id, [session.user.id]);
}
}
return true;
},
async jwt({ token, session }) {
if (session) {
token.locale = session.locale;
token.timeFormat = session.timeFormat;
token.timeZone = session.timeZone;
token.weekStart = session.weekStart;
}
return token;
},
async session({ session, token }) {
if (!token.sub) {
return session;
}
if (token.sub?.startsWith("user-")) {
session.user = {
id: token.sub as string,
locale: token.locale,
timeFormat: token.timeFormat,
timeZone: token.timeZone,
weekStart: token.weekStart,
};
return session;
}
const user = await prisma.user.findUnique({
where: {
id: token.sub as string,
},
select: {
id: true,
name: true,
timeFormat: true,
timeZone: true,
locale: true,
weekStart: true,
email: true,
image: true,
},
});
if (user) {
session.user = {
id: user.id,
name: user.name,
email: user.email,
image: user.image,
locale: user.locale,
timeFormat: user.timeFormat,
timeZone: user.timeZone,
weekStart: user.weekStart,
};
}
return session;
},
},
}) satisfies NextAuthOptions;
type GetServerSessionParams =
| [GetServerSidePropsContext["req"], GetServerSidePropsContext["res"]]
| [NextApiRequest, NextApiResponse]
| [];
export async function getServerSession(...args: GetServerSessionParams) {
return await getServerSessionWithOptions(...args, getAuthOptions(...args));
}
export async function AuthApiRoute(req: NextApiRequest, res: NextApiResponse) {
const authOptions = getAuthOptions(req, res);
return NextAuth(req, res, authOptions);
}
export const isEmailBlocked = (email: string) => {
if (process.env.ALLOWED_EMAILS) {
const allowedEmails = process.env.ALLOWED_EMAILS.split(",");
// Check whether the email matches enough of the patterns specified in ALLOWED_EMAILS
const isAllowed = allowedEmails.some((allowedEmail) => {
const regex = new RegExp(
`^${allowedEmail
.replace(/[.+?^${}()|[\]\\]/g, "\\$&")
.replaceAll(/[*]/g, ".*")}$`,
);
return regex.test(email);
});
if (!isAllowed) {
return true;
}
}
return false;
};
export function getOAuthProviders(): {
id: string;
name: string;
}[] {
return providers
.filter((provider) => provider.type === "oauth")
.map((provider) => {
return {
id: provider.id,
name: provider.options?.name || provider.name,
};
});
}

View file

@ -10,19 +10,18 @@
* See: https://github.com/lukevella/rallly/issues/949
*/
import { PrismaAdapter } from "@auth/prisma-adapter";
import type { ExtendedPrismaClient, PrismaClient } from "@rallly/database";
import type { Adapter, AdapterAccount } from "next-auth/adapters";
import { prisma } from "@rallly/database";
import type { Adapter } from "next-auth/adapters";
export function CustomPrismaAdapter(
client: ExtendedPrismaClient,
options: { migrateData: (userId: string) => Promise<void> },
) {
const adapter = PrismaAdapter(client as PrismaClient);
export function CustomPrismaAdapter(options: {
migrateData: (userId: string) => Promise<void>;
}) {
const adapter = PrismaAdapter(prisma);
return {
...adapter,
linkAccount: async (account: AdapterAccount) => {
linkAccount: async (account) => {
await options.migrateData(account.userId);
return (await client.account.create({
return prisma.account.create({
data: {
userId: account.userId,
type: account.type,
@ -36,7 +35,7 @@ export function CustomPrismaAdapter(
scope: account.scope as string,
session_state: account.session_state as string,
},
})) as AdapterAccount;
});
},
} satisfies Adapter;
} as Adapter;
}

View file

@ -0,0 +1,11 @@
import type { Provider } from "next-auth/providers/index";
import { GoogleProvider } from "../providers/google";
import { MicrosoftProvider } from "../providers/microsoft";
import { OIDCProvider } from "../providers/oidc";
export function getOptionalProviders() {
return [OIDCProvider(), GoogleProvider(), MicrosoftProvider()].filter(
Boolean,
) as Provider[];
}

View file

@ -0,0 +1,19 @@
export const isEmailBlocked = (email: string) => {
if (process.env.ALLOWED_EMAILS) {
const allowedEmails = process.env.ALLOWED_EMAILS.split(",");
// Check whether the email matches enough of the patterns specified in ALLOWED_EMAILS
const isAllowed = allowedEmails.some((allowedEmail) => {
const regex = new RegExp(
`^${allowedEmail
.replace(/[.+?^${}()|[\]\\]/g, "\\$&")
.replaceAll(/[*]/g, ".*")}$`,
);
return regex.test(email);
});
if (!isAllowed) {
return true;
}
}
return false;
};

View file

@ -0,0 +1,30 @@
import hkdf from "@panva/hkdf";
import { jwtDecrypt } from "jose";
import type { JWT } from "./types";
/** Decodes a NextAuth.js issued JWT. */
export async function decodeLegacyJWT(token: string): Promise<JWT | null> {
if (!token) return null;
const encryptionSecret = await getDerivedEncryptionKey(
process.env.SECRET_PASSWORD,
"",
);
const { payload } = await jwtDecrypt(token, encryptionSecret, {
clockTolerance: 15,
});
return payload;
}
async function getDerivedEncryptionKey(
keyMaterial: string | Buffer,
salt: string,
) {
return await hkdf(
"sha256",
keyMaterial,
salt,
`NextAuth.js Generated Encryption Key${salt ? ` (${salt})` : ""}`,
32,
);
}

View file

@ -0,0 +1,46 @@
export interface DefaultJWT extends Record<string, unknown> {
name?: string | null;
email?: string | null;
picture?: string | null;
sub?: string;
}
/**
* Returned by the `jwt` callback and `getToken`, when using JWT sessions
*
* [`jwt` callback](https://next-auth.js.org/configuration/callbacks#jwt-callback) | [`getToken`](https://next-auth.js.org/tutorials/securing-pages-and-api-routes#using-gettoken)
*/
export interface JWT extends Record<string, unknown>, DefaultJWT {}
export interface JWTEncodeParams {
/** The JWT payload. */
token?: JWT;
/**
* Used in combination with `secret` when deriving the encryption secret for the various NextAuth.js-issued JWTs.
* @note When no `salt` is passed, we assume this is a session token.
* This is for backwards-compatibility with currently active sessions, so they won't be invalidated when upgrading the package.
*/
salt?: string;
/** The key material used to encode the NextAuth.js issued JWTs. Defaults to `NEXTAUTH_SECRET`. */
secret: string | Buffer;
/**
* The maximum age of the NextAuth.js issued JWT in seconds.
* @default 30 * 24 * 60 * 60 // 30 days
*/
maxAge?: number;
}
export interface JWTDecodeParams {
/** The NextAuth.js issued JWT to be decoded */
token?: string;
/**
* Used in combination with `secret` when deriving the encryption secret for the various NextAuth.js-issued JWTs.
* @note When no `salt` is passed, we assume this is a session token.
* This is for backwards-compatibility with currently active sessions, so they won't be invalidated when upgrading the package.
*/
salt?: string;
/** The key material used to decode the NextAuth.js issued JWTs. Defaults to `NEXTAUTH_SECRET`. */
secret: string | Buffer;
}
export type Secret = string | Buffer;

View file

@ -0,0 +1,65 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { encode } from "next-auth/jwt";
import { decodeLegacyJWT } from "./helpers/jwt";
const isSecureCookie =
process.env.NEXT_PUBLIC_BASE_URL?.startsWith("https://") ?? false;
const prefix = isSecureCookie ? "__Secure-" : "";
const oldCookieName = prefix + "next-auth.session-token";
const newCookieName = prefix + "authjs.session-token";
/**
* Migrates the next-auth cookies to the new authjs cookie names
* This is needed for next-auth v5 which renamed the cookie prefix from 'next-auth' to 'authjs'
*/
export function withAuthMigration(
middleware: (req: NextRequest) => void | Response | Promise<void | Response>,
) {
return async (req: NextRequest) => {
const oldCookie = req.cookies.get(oldCookieName);
// If the old cookie doesn't exist, return the middleware
if (!oldCookie) {
return middleware(req);
}
const response = NextResponse.redirect(req.url);
response.cookies.delete(oldCookieName);
// If the new cookie exists, delete the old cookie first and rerun middleware
if (req.cookies.get(newCookieName)) {
return response;
}
const decodedCookie = await decodeLegacyJWT(oldCookie.value);
// If old cookie is invalid, delete the old cookie first and rerun middleware
if (!decodedCookie) {
return response;
}
// Set the new cookie
const encodedCookie = await encode({
token: decodedCookie,
secret: process.env.SECRET_PASSWORD,
salt: newCookieName,
});
// Set the new cookie with the same value and attributes
response.cookies.set(newCookieName, encodedCookie, {
path: "/",
secure: isSecureCookie,
sameSite: "lax",
httpOnly: true,
});
// Delete the old cookie
response.cookies.delete(oldCookieName);
return response;
};
}

View file

@ -0,0 +1,15 @@
import type { NextRequest } from "next/server";
import type { NextAuthRequest } from "next-auth";
import NextAuth from "next-auth";
import { nextAuthConfig } from "@/next-auth.config";
const { auth } = NextAuth(nextAuthConfig);
export function withAuth(
middleware: (
req: NextAuthRequest,
) => void | Response | Promise<void | Response>,
): (req: NextRequest) => void | Response | Promise<void | Response> {
return (req: NextRequest) => auth(middleware)(req, undefined as never);
}

View file

@ -0,0 +1,41 @@
import { prisma } from "@rallly/database";
import { absoluteUrl } from "@rallly/utils/absolute-url";
import { generateOtp } from "@rallly/utils/nanoid";
import NodemailerProvider from "next-auth/providers/nodemailer";
import { getEmailClient } from "@/utils/emails";
export const EmailProvider = NodemailerProvider({
server: "none", // This value is required even though we don't need it
from: process.env.NOREPLY_EMAIL,
id: "email",
generateVerificationToken() {
return generateOtp();
},
async sendVerificationRequest({ identifier: email, token, url }) {
const user = await prisma.user.findUnique({
where: {
email,
},
select: {
name: true,
locale: true,
},
});
if (user) {
await getEmailClient(user.locale ?? undefined).sendTemplate(
"LoginEmail",
{
to: email,
props: {
magicLink: absoluteUrl("/auth/login", {
magicLink: url,
}),
code: token,
},
},
);
}
},
});

View file

@ -0,0 +1,11 @@
import BaseGoogleProvider from "next-auth/providers/google";
export function GoogleProvider() {
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
return BaseGoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
allowDangerousEmailAccountLinking: true,
});
}
}

View file

@ -0,0 +1,14 @@
import { randomid } from "@rallly/utils/nanoid";
import CredentialsProvider from "next-auth/providers/credentials";
export const GuestProvider = CredentialsProvider({
id: "guest",
name: "Guest",
credentials: {},
async authorize() {
return {
id: `user-${randomid()}`,
email: null,
};
},
});

View file

@ -0,0 +1,17 @@
import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id";
export function MicrosoftProvider() {
if (
process.env.MICROSOFT_TENANT_ID &&
process.env.MICROSOFT_CLIENT_ID &&
process.env.MICROSOFT_CLIENT_SECRET
) {
return MicrosoftEntraID({
name: "Microsoft",
clientId: process.env.MICROSOFT_CLIENT_ID,
clientSecret: process.env.MICROSOFT_CLIENT_SECRET,
wellKnown:
"https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration",
});
}
}

View file

@ -0,0 +1,34 @@
import type { User } from "next-auth";
import type { OIDCConfig } from "next-auth/providers/index";
import { env } from "@/env";
import { getValueByPath } from "@/utils/get-value-by-path";
export const OIDCProvider = () => {
if (
process.env.OIDC_DISCOVERY_URL &&
process.env.OIDC_CLIENT_ID &&
process.env.OIDC_CLIENT_SECRET
) {
return {
id: "oidc",
name: process.env.OIDC_NAME ?? "OpenID Connect",
type: "oidc",
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: getValueByPath(profile, env.OIDC_NAME_CLAIM_PATH),
email: getValueByPath(profile, env.OIDC_EMAIL_CLAIM_PATH),
image: getValueByPath(profile, env.OIDC_PICTURE_CLAIM_PATH),
} as User;
},
} satisfies OIDCConfig<Record<string, unknown>>;
}
};

View file

@ -0,0 +1,47 @@
import { prisma } from "@rallly/database";
import CredentialsProvider from "next-auth/providers/credentials";
import type { RegistrationTokenPayload } from "@/trpc/types";
import { decryptToken } from "@/utils/session";
// When a user registers, we don't want to go through the email verification process
// so this provider allows us exchange the registration token for a session token
export const RegistrationTokenProvider = 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 as string,
);
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,
image: true,
},
});
if (user) {
return user;
}
}
}
return null;
},
});

View file

@ -1,8 +0,0 @@
"use client";
import type { SessionProviderProps } from "next-auth/react";
import { SessionProvider as NextAuthSessionProvider } from "next-auth/react";
export function SessionProvider(props: SessionProviderProps) {
return <NextAuthSessionProvider {...props} />;
}

View file

@ -12,7 +12,7 @@ export const LoginLink = React.forwardRef<
<Link
ref={ref}
{...props}
href={`/login?callbackUrl=${encodeURIComponent(pathname)}`}
href={`/login?redirectTo=${encodeURIComponent(pathname)}`}
>
{children}
</Link>

View file

@ -18,7 +18,7 @@ export const LanguageSelect: React.FunctionComponent<{
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger asChild className={className}>
<Button variant="ghost">
<Button>
<Icon>
<GlobeIcon />
</Icon>

View file

@ -17,7 +17,7 @@ export const RegisterLink = React.forwardRef<
onClick={async (e) => {
e.preventDefault();
props.onClick?.(e);
router.push("/register?callbackUrl=" + encodeURIComponent(pathname));
router.push("/register?redirectTo=" + encodeURIComponent(pathname));
}}
>
{children}

View file

@ -1,9 +1,9 @@
import { prisma } from "@rallly/database";
import { getServerSession } from "@/auth";
import { auth } from "@/next-auth";
export async function getGuestPolls() {
const session = await getServerSession();
const session = await auth();
const user = session?.user;
const guestId = !user?.email ? user?.id : null;

View file

@ -1,32 +1,19 @@
import languages from "@rallly/languages";
import { withPostHog } from "@rallly/posthog/next/middleware";
import { NextResponse } from "next/server";
import withAuth from "next-auth/middleware";
import { getLocaleFromHeader } from "@/app/guest";
import { isSelfHosted } from "@/utils/constants";
import { withAuthMigration } from "@/auth/legacy/next-auth-cookie-migration";
import { withAuth } from "@/auth/middleware";
const supportedLocales = Object.keys(languages);
const publicRoutes = [
"/login",
"/register",
"/invite/",
"/poll/",
"/auth/login",
];
if (process.env.QUICK_CREATE_ENABLED === "true") {
publicRoutes.push("/quick-create", "/new");
}
export const middleware = withAuth(
async function middleware(req) {
export const middleware = withAuthMigration(
withAuth(async (req) => {
const { nextUrl } = req;
const newUrl = nextUrl.clone();
const isLoggedIn = req.nextauth.token?.email;
// set x-pathname header to the pathname
const isLoggedIn = req.auth?.user?.email;
// if the user is already logged in, don't let them access the login page
if (/^\/(login)/.test(newUrl.pathname) && isLoggedIn) {
@ -34,63 +21,25 @@ export const middleware = withAuth(
return NextResponse.redirect(newUrl);
}
// if the user is not logged in and the page is not public, redirect to login
if (
!isLoggedIn &&
!publicRoutes.some((route) => newUrl.pathname.startsWith(route))
) {
if (newUrl.pathname !== "/") {
newUrl.searchParams.set("callbackUrl", newUrl.pathname);
}
newUrl.pathname = "/login";
return NextResponse.redirect(newUrl);
}
// Check if locale is specified in cookie
let locale = req.nextauth.token?.locale;
let locale = req.auth?.user?.locale;
if (locale && supportedLocales.includes(locale)) {
newUrl.pathname = `/${locale}${newUrl.pathname}`;
} else {
// Check if locale is specified in header
locale = await getLocaleFromHeader(req);
newUrl.pathname = `/${locale}${newUrl.pathname}`;
}
const res = NextResponse.rewrite(newUrl);
res.headers.set("x-pathname", newUrl.pathname);
if (req.nextauth.token) {
await withPostHog(res, { distinctID: req.nextauth.token.sub });
if (req.auth?.user?.id) {
await withPostHog(res, { distinctID: req.auth.user.id });
}
return res;
},
{
secret: process.env.SECRET_PASSWORD,
callbacks: {
authorized: ({ token, req }) => {
const nextUrl = req.nextUrl;
const isGuest = !token?.email;
if (
isSelfHosted &&
isGuest &&
!(
nextUrl.pathname.startsWith("/invite") ||
nextUrl.pathname.startsWith("/login") ||
nextUrl.pathname.startsWith("/register") ||
nextUrl.pathname.startsWith("/auth") ||
nextUrl.pathname.startsWith("/p/")
)
) {
// limit which pages guests can access for self-hosted instances
return false;
}
return true;
},
},
},
}),
);
export const config = {

View file

@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
import type { NextAuthConfig } from "next-auth";
import { env } from "@/env";
import { isQuickCreateEnabled } from "@/features/quick-create/constants";
const publicRoutes = ["/login", "/register", "/invite/", "/poll/", "/auth"];
if (isQuickCreateEnabled) {
publicRoutes.push("/quick-create", "/new");
}
/**
* We split the next-auth config so that we can create an edge compatible instance that is
* used in middleware.
*/
export const nextAuthConfig = {
session: {
strategy: "jwt",
},
secret: env.SECRET_PASSWORD,
providers: [],
callbacks: {
async session({ session, token }) {
session.user.id = token.sub as string;
session.user.email = token.email as string;
session.user.locale = token.locale;
session.user.timeFormat = token.timeFormat;
session.user.timeZone = token.timeZone;
session.user.weekStart = token.weekStart;
return session;
},
async authorized({ request, auth }) {
const { nextUrl } = request;
const isLoggedIn = !!auth?.user?.email;
const isPublicRoute = publicRoutes.some((route) =>
nextUrl.pathname.startsWith(route),
);
if (isLoggedIn || isPublicRoute) {
return true;
}
const redirectUrl = new URL("/login", request.url);
if (nextUrl.pathname !== "/") {
const redirectPath = nextUrl.pathname + nextUrl.search;
redirectUrl.searchParams.set("redirectTo", redirectPath);
}
return NextResponse.redirect(redirectUrl);
},
},
} satisfies NextAuthConfig;

169
apps/web/src/next-auth.ts Normal file
View file

@ -0,0 +1,169 @@
import { prisma } from "@rallly/database";
import { posthog } from "@rallly/posthog/server";
import NextAuth from "next-auth";
import type { Provider } from "next-auth/providers";
import z from "zod";
import { CustomPrismaAdapter } from "./auth/adapters/prisma";
import { isEmailBlocked } from "./auth/helpers/is-email-blocked";
import { mergeGuestsIntoUser } from "./auth/helpers/merge-user";
import { EmailProvider } from "./auth/providers/email";
import { GoogleProvider } from "./auth/providers/google";
import { GuestProvider } from "./auth/providers/guest";
import { MicrosoftProvider } from "./auth/providers/microsoft";
import { OIDCProvider } from "./auth/providers/oidc";
import { RegistrationTokenProvider } from "./auth/providers/registration-token";
import { nextAuthConfig } from "./next-auth.config";
const sessionUpdateSchema = z.object({
locale: z.string().nullish(),
timeFormat: z.enum(["12h", "24h"]).nullish(),
timeZone: z.string().nullish(),
weekStart: z.number().nullish(),
});
export const { auth, handlers, signIn, signOut } = NextAuth({
...nextAuthConfig,
adapter: CustomPrismaAdapter({
migrateData: async (userId) => {
const session = await auth();
if (session?.user && session.user.email === null) {
await mergeGuestsIntoUser(userId, [session.user.id]);
}
},
}),
providers: [
RegistrationTokenProvider,
EmailProvider,
GuestProvider,
...([GoogleProvider(), OIDCProvider(), MicrosoftProvider()].filter(
Boolean,
) as Provider[]),
],
pages: {
signIn: "/login",
verifyRequest: "/login/verify",
error: "/auth/error",
},
session: {
strategy: "jwt",
},
events: {
signIn({ user, account }) {
if (user.id) {
posthog?.capture({
distinctId: user.id,
event: "login",
properties: {
method: account?.provider,
$set: {
name: user.name,
email: user.email,
timeZone: user.timeZone,
locale: user.locale,
},
},
});
}
},
},
callbacks: {
...nextAuthConfig.callbacks,
async signIn({ user, email, profile }) {
const distinctId = user.id;
// prevent sign in if email is not verified
if (
profile &&
"email_verified" in profile &&
profile.email_verified === false &&
distinctId
) {
posthog?.capture({
distinctId,
event: "login failed",
properties: {
reason: "email not verified",
},
});
return false;
}
// Make sure email is allowed
if (user.email) {
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 isRegisteredUser =
(await prisma.user.count({
where: {
email: user.email as string,
},
})) > 0;
return isRegisteredUser;
}
// when we login with a social account for the first time, the user is not created yet
// and the user id will be the same as the provider account id
// we handle this case the the prisma adapter when we link accounts
const isInitialSocialLogin = user.id === profile?.sub;
if (!isInitialSocialLogin) {
// merge guest user into newly logged in user
const session = await auth();
if (user.id && session?.user && !session.user.email) {
await mergeGuestsIntoUser(user.id, [session.user.id]);
}
}
return true;
},
async jwt({ token, session, trigger }) {
if (trigger === "update") {
const parsed = sessionUpdateSchema.safeParse(session);
if (parsed.success) {
Object.entries(parsed.data).forEach(([key, value]) => {
token[key] = value;
});
} else {
console.error(parsed.error);
}
} else {
const userId = token.sub;
const isGuest = userId?.startsWith("guest-");
if (userId && !isGuest) {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
email: true,
locale: true,
timeFormat: true,
timeZone: true,
weekStart: true,
},
});
if (user) {
token.email = user.email;
token.locale = user.locale;
token.timeFormat = user.timeFormat;
token.timeZone = user.timeZone;
token.weekStart = user.weekStart;
}
}
}
return token;
},
},
});

View file

@ -1,14 +0,0 @@
import { posthogApiHandler } from "@rallly/posthog/server";
import type { NextApiRequest, NextApiResponse } from "next";
import { AuthApiRoute } from "@/auth";
import { composeApiHandlers } from "@/utils/next";
export default async function auth(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "HEAD") {
res.status(200).end();
res.setHeader("Content-Length", "0");
} else {
return composeApiHandlers(AuthApiRoute, posthogApiHandler)(req, res);
}
}

View file

@ -4,8 +4,8 @@ import { generateOtp } from "@rallly/utils/nanoid";
import * as Sentry from "@sentry/nextjs";
import { z } from "zod";
import { isEmailBlocked } from "@/auth";
import { mergeGuestsIntoUser } from "@/auth/merge-user";
import { isEmailBlocked } from "@/auth/helpers/is-email-blocked";
import { mergeGuestsIntoUser } from "@/auth/helpers/merge-user";
import { getEmailClient } from "@/utils/emails";
import { createToken, decryptToken } from "@/utils/session";

View file

@ -2,14 +2,14 @@ import { createServerSideHelpers } from "@trpc/react-query/server";
import { redirect } from "next/navigation";
import superjson from "superjson";
import { getServerSession } from "@/auth";
import { auth } from "@/next-auth";
import { getEmailClient } from "@/utils/emails";
import type { TRPCContext } from "../context";
import { appRouter } from "../routers";
async function createContext(): Promise<TRPCContext> {
const session = await getServerSession();
const session = await auth();
return {
user: session?.user
? {

View file

@ -31,6 +31,6 @@ test.describe.serial(() => {
deletePollDialog.getByRole("button", { name: "delete" }).click();
await expect(page).toHaveURL("/login?callbackUrl=%2Fpolls");
await expect(page).toHaveURL("/login?redirectTo=%2Fpolls");
});
});

View file

@ -58,5 +58,6 @@
"engines": {
"node": "20.x"
},
"packageManager": "yarn@1.22.22"
"packageManager": "yarn@1.22.22",
"dependencies": {}
}

View file

@ -1,4 +1,5 @@
import { waitUntil } from "@vercel/functions";
import type { NextRequest } from "next/server";
import { PostHog } from "posthog-node";
function PostHogClient() {
@ -14,10 +15,14 @@ function PostHogClient() {
export const posthog = PostHogClient();
export function posthogApiHandler() {
try {
waitUntil(Promise.all([posthog?.shutdown()]));
} catch (error) {
console.error("Failed to flush PostHog events:", error);
}
export function withPosthog(handler: (req: NextRequest) => Promise<Response>) {
return async (req: NextRequest) => {
const res = await handler(req);
try {
waitUntil(Promise.all([posthog?.shutdown()]));
} catch (error) {
console.error("Failed to flush PostHog events:", error);
}
return res;
};
}

View file

@ -2,7 +2,6 @@
set -e
export DIRECT_DATABASE_URL=$DATABASE_URL
export NEXTAUTH_URL=$NEXT_PUBLIC_BASE_URL
prisma migrate deploy --schema=./prisma/schema.prisma
node apps/web/server.js

142
yarn.lock
View file

@ -20,24 +20,36 @@
"@jridgewell/gen-mapping" "^0.1.0"
"@jridgewell/trace-mapping" "^0.3.9"
"@auth/core@0.16.1":
version "0.16.1"
resolved "https://registry.npmjs.org/@auth/core/-/core-0.16.1.tgz"
integrity sha512-V+YifnjpyOadiiTbxfYDV2xYWo8xpKNtwYVskAEKUSwMvE0FlSlP+10QGBpf0axS/AJFOO61IR6GncFF/IOrHQ==
"@auth/core@0.37.2":
version "0.37.2"
resolved "https://registry.yarnpkg.com/@auth/core/-/core-0.37.2.tgz#0db8a94a076846bd88eb7f9273618513e2285cb2"
integrity sha512-kUvzyvkcd6h1vpeMAojK2y7+PAV5H+0Cc9+ZlKYDFhDY31AlvsB+GW5vNO4qE3Y07KeQgvNO9U0QUx/fN62kBw==
dependencies:
"@panva/hkdf" "^1.0.4"
cookie "0.5.0"
jose "^4.11.1"
oauth4webapi "^2.0.6"
"@panva/hkdf" "^1.2.1"
"@types/cookie" "0.6.0"
cookie "0.7.1"
jose "^5.9.3"
oauth4webapi "^3.0.0"
preact "10.11.3"
preact-render-to-string "5.2.3"
"@auth/prisma-adapter@^1.0.3":
version "1.0.3"
resolved "https://registry.npmjs.org/@auth/prisma-adapter/-/prisma-adapter-1.0.3.tgz"
integrity sha512-AMwQbO7OiBYRCA6VNfv9CpcpiRh0BP4EKhPdtO+pom9Uhuor2ioE4IqvhUfJyBkSjAP2Gt9WbKqr9kzL9LrtIg==
"@auth/core@0.37.4":
version "0.37.4"
resolved "https://registry.yarnpkg.com/@auth/core/-/core-0.37.4.tgz#c51410aa7d0997fa22a07a196d2c21c8b1bca71b"
integrity sha512-HOXJwXWXQRhbBDHlMU0K/6FT1v+wjtzdKhsNg0ZN7/gne6XPsIrjZ4daMcFnbq0Z/vsAbYBinQhhua0d77v7qw==
dependencies:
"@auth/core" "0.16.1"
"@panva/hkdf" "^1.2.1"
jose "^5.9.6"
oauth4webapi "^3.1.1"
preact "10.24.3"
preact-render-to-string "6.5.11"
"@auth/prisma-adapter@^2.7.4":
version "2.7.4"
resolved "https://registry.yarnpkg.com/@auth/prisma-adapter/-/prisma-adapter-2.7.4.tgz#4890be47a9f227f449832302d955c565c02879ee"
integrity sha512-3T/X94R9J1sxOLQtsD3ijIZ0JGHPXlZQxRr/8NpnZBJ3KGxun/mNsZ1MwMRhTxy0mmn9JWXk7u9+xCcVn0pu3A==
dependencies:
"@auth/core" "0.37.4"
"@aws-crypto/crc32@3.0.0":
version "3.0.0"
@ -3378,10 +3390,10 @@
dependencies:
"@opentelemetry/core" "^1.1.0"
"@panva/hkdf@^1.0.2", "@panva/hkdf@^1.0.4":
version "1.1.1"
resolved "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.1.1.tgz"
integrity sha512-dhPeilub1NuIG0X5Kvhh9lH4iW3ZsHlnzwgwbOlgwQ2wG1IqFzsgHqmKPk3WzsdWAeaxKJxgM0+W433RmN45GA==
"@panva/hkdf@^1.2.1":
version "1.2.1"
resolved "https://registry.yarnpkg.com/@panva/hkdf/-/hkdf-1.2.1.tgz#cb0d111ef700136f4580349ff0226bf25c853f23"
integrity sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==
"@peculiar/asn1-schema@^2.1.6", "@peculiar/asn1-schema@^2.3.0":
version "2.3.3"
@ -6128,6 +6140,11 @@
resolved "https://registry.npmjs.org/@types/content-disposition/-/content-disposition-0.5.5.tgz"
integrity sha512-v6LCdKfK6BwcqMo+wYW05rLS12S0ZO0Fl4w1h4aaZMD7bqT3gVUns6FvLJKGZHQmYn3SX55JWGpziwJRwVgutA==
"@types/cookie@0.6.0":
version "0.6.0"
resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.6.0.tgz#eac397f28bf1d6ae0ae081363eca2f425bedf0d5"
integrity sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==
"@types/cookie@^0.4.1":
version "0.4.1"
resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.4.1.tgz#bfd02c1f2224567676c1545199f87c3a861d878d"
@ -7521,9 +7538,9 @@ camelcase@^6.2.0:
integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==
caniuse-lite@^1.0.30001406, caniuse-lite@^1.0.30001426, caniuse-lite@^1.0.30001449, caniuse-lite@^1.0.30001464, caniuse-lite@^1.0.30001579, caniuse-lite@^1.0.30001580, caniuse-lite@^1.0.30001629:
version "1.0.30001636"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001636.tgz#b15f52d2bdb95fad32c2f53c0b68032b85188a78"
integrity sha512-bMg2vmr8XBsbL6Lr0UHXy/21m84FTxDLWn2FSqMd5PrlbMxwJlQnC2YWYxVgp66PZE+BBNF2jYQUBKCo1FDeZg==
version "1.0.30001697"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001697.tgz"
integrity sha512-GwNPlWJin8E+d7Gxq96jxM6w0w+VFeyyXRsjU58emtkYqnbwHqXm5uT2uCmO0RQE9htWknOP4xtBlLmM/gWxvQ==
ccount@^2.0.0:
version "2.0.1"
@ -7855,7 +7872,12 @@ convert-source-map@^2.0.0:
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a"
integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==
cookie@0.5.0, cookie@^0.5.0:
cookie@0.7.1:
version "0.7.1"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.1.tgz#2f73c42142d5d5cf71310a74fc4ae61670e5dbc9"
integrity sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==
cookie@^0.5.0:
version "0.5.0"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b"
integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==
@ -10396,12 +10418,7 @@ joi@^17.6.0:
"@sideway/formula" "^3.0.1"
"@sideway/pinpoint" "^2.0.0"
jose@^4.11.1, jose@^4.11.4, jose@^4.15.1:
version "4.15.5"
resolved "https://registry.yarnpkg.com/jose/-/jose-4.15.5.tgz#6475d0f467ecd3c630a1b5dadd2735a7288df706"
integrity sha512-jc7BFxgKPKi94uOvEmzlSWFFe2+vASyXaKUpdQKatWAESU2MWjDfFf0fdfc83CDKcA5QecabZeNLyfhe3yKNkg==
jose@^5.2.3:
jose@^5.2.3, jose@^5.9.3, jose@^5.9.6:
version "5.9.6"
resolved "https://registry.yarnpkg.com/jose/-/jose-5.9.6.tgz#77f1f901d88ebdc405e57cce08d2a91f47521883"
integrity sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==
@ -11364,20 +11381,12 @@ neverthrow@^7.0.1:
resolved "https://registry.yarnpkg.com/neverthrow/-/neverthrow-7.2.0.tgz#76fa0a6cf1f6d59f0770df461c92b8b270910694"
integrity sha512-iGBUfFB7yPczHHtA8dksKTJ9E8TESNTAx1UQWW6TzMF280vo9jdPYpLUXrMN1BCkPdHFdNG3fxOt2CUad8KhAw==
next-auth@^4.24.5:
version "4.24.5"
resolved "https://registry.yarnpkg.com/next-auth/-/next-auth-4.24.5.tgz#1fd1bfc0603c61fd2ba6fd81b976af690edbf07e"
integrity sha512-3RafV3XbfIKk6rF6GlLE4/KxjTcuMCifqrmD+98ejFq73SRoj2rmzoca8u764977lH/Q7jo6Xu6yM+Re1Mz/Og==
next-auth@^5.0.0-beta.25:
version "5.0.0-beta.25"
resolved "https://registry.yarnpkg.com/next-auth/-/next-auth-5.0.0-beta.25.tgz#3a9f9734e1d8fa5ced545360f1afc24862cb92d5"
integrity sha512-2dJJw1sHQl2qxCrRk+KTQbeH+izFbGFPuJj5eGgBZFYyiYYtvlrBeUw1E/OJJxTRjuxbSYGnCTkUIRsIIW0bog==
dependencies:
"@babel/runtime" "^7.20.13"
"@panva/hkdf" "^1.0.2"
cookie "^0.5.0"
jose "^4.11.4"
oauth "^0.9.15"
openid-client "^5.4.0"
preact "^10.6.3"
preact-render-to-string "^5.1.19"
uuid "^8.3.2"
"@auth/core" "0.37.2"
next-i18next@^13.0.3:
version "13.1.6"
@ -11543,26 +11552,16 @@ nth-check@^2.0.1:
dependencies:
boolbase "^1.0.0"
oauth4webapi@^2.0.6:
version "2.3.0"
resolved "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-2.3.0.tgz"
integrity sha512-JGkb5doGrwzVDuHwgrR4nHJayzN4h59VCed6EW8Tql6iHDfZIabCJvg6wtbn5q6pyB2hZruI3b77Nudvq7NmvA==
oauth@^0.9.15:
version "0.9.15"
resolved "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz"
integrity sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==
oauth4webapi@^3.0.0, oauth4webapi@^3.1.1:
version "3.1.4"
resolved "https://registry.yarnpkg.com/oauth4webapi/-/oauth4webapi-3.1.4.tgz#50695385cea8e7a43f3e2e23bc33ea27faece4a7"
integrity sha512-eVfN3nZNbok2s/ROifO0UAc5G8nRoLSbrcKJ09OqmucgnhXEfdIQOR4gq1eJH1rN3gV7rNw62bDEgftsgFtBEg==
object-assign@^4, object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1:
version "4.1.1"
resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz"
integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
object-hash@^2.2.0:
version "2.2.0"
resolved "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz"
integrity sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==
object-hash@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz"
@ -11656,11 +11655,6 @@ obuf@~1.1.2:
resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e"
integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==
oidc-token-hash@^5.0.3:
version "5.0.3"
resolved "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz"
integrity sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==
once@^1.3.0, once@^1.3.1, once@^1.3.2, once@^1.4.0:
version "1.4.0"
resolved "https://registry.npmjs.org/once/-/once-1.4.0.tgz"
@ -11680,16 +11674,6 @@ opener@^1.5.2:
resolved "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz"
integrity sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==
openid-client@^5.4.0:
version "5.6.0"
resolved "https://registry.npmjs.org/openid-client/-/openid-client-5.6.0.tgz"
integrity sha512-uFTkN/iqgKvSnmpVAS/T6SNThukRMBcmymTQ71Ngus1F60tdtKVap7zCrleocY+fogPtpmoxi5Q1YdrgYuTlkA==
dependencies:
jose "^4.15.1"
lru-cache "^6.0.0"
object-hash "^2.2.0"
oidc-token-hash "^5.0.3"
optionator@^0.9.3:
version "0.9.3"
resolved "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz"
@ -12162,28 +12146,26 @@ preact-render-to-string@5.2.3:
dependencies:
pretty-format "^3.8.0"
preact-render-to-string@^5.1.19:
version "5.2.6"
resolved "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz"
integrity sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==
dependencies:
pretty-format "^3.8.0"
preact-render-to-string@6.5.11:
version "6.5.11"
resolved "https://registry.yarnpkg.com/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz#467e69908a453497bb93d4d1fc35fb749a78e027"
integrity sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==
preact@10.11.3:
version "10.11.3"
resolved "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz"
integrity sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==
preact@10.24.3:
version "10.24.3"
resolved "https://registry.yarnpkg.com/preact/-/preact-10.24.3.tgz#086386bd47071e3b45410ef20844c21e23828f64"
integrity sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==
preact@^10.19.3:
version "10.19.3"
resolved "https://registry.yarnpkg.com/preact/-/preact-10.19.3.tgz#7a7107ed2598a60676c943709ea3efb8aaafa899"
integrity sha512-nHHTeFVBTHRGxJXKkKu5hT8C/YWBkPso4/Gad6xuj5dbptt9iF9NZr9pHbPhBrnT2klheu7mHTxTZ/LjwJiEiQ==
preact@^10.6.3:
version "10.18.1"
resolved "https://registry.npmjs.org/preact/-/preact-10.18.1.tgz"
integrity sha512-mKUD7RRkQQM6s7Rkmi7IFkoEHjuFqRQUaXamO61E6Nn7vqF/bo7EZCmSyrUnp2UWHw0O7XjZ2eeXis+m7tf4lg==
prelude-ls@^1.2.1:
version "1.2.1"
resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz"