Add back vc auth temporarily to unblock things

This commit is contained in:
Jared Palmer 2023-05-19 14:13:38 -04:00
parent 07c2e7d36a
commit 177b2cc210
20 changed files with 350 additions and 40 deletions

View file

@ -1,12 +1,12 @@
"use server"; "use server";
import { getServerSession } from "next-auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getServerSession } from "@/lib/session/get-server-session";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
export async function removeChat({ id, path }: { id: string; path: string }) { export async function removeChat({ id, path }: { id: string; path: string }) {
const session = await getServerSession(authOptions); const session = await getServerSession();
const userId = session?.user?.email; const userId = session?.user?.email;
if (!userId || !id) { if (!userId || !id) {
throw new Error("Unauthorized"); throw new Error("Unauthorized");

View file

@ -1,18 +0,0 @@
import NextAuth, { NextAuthOptions } from "next-auth";
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
import GoogleProvider from "next-auth/providers/google";
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID as string,
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
}),
],
};
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

View file

@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server';
import { serialize } from 'cookie';
import { createSession } from '@/lib/session/create';
import { encryptJWECookie } from '@/lib/cookies/encrypt-jwe';
import ms from 'ms';
import { userSessionCookieName } from '@/lib/session/constants';
export const runtime = 'edge';
const sharedTokenSecret = process.env.SHARE_TOKEN_ENDPOINT_SECRET;
const cookieTTL = ms('1y');
export async function GET(req: NextRequest) {
const { origin, searchParams } = req.nextUrl;
const shareableToken = searchParams.get('shareableToken');
if (!sharedTokenSecret || !shareableToken) {
// TODO: Make an error page
return NextResponse.json({ error: 'Invalid request' }, { status: 500 });
}
const query = new URLSearchParams({
sharedTokenSecret,
});
const response = await fetch(
`https://api.vercel.com/user/tokens/shared/${shareableToken}?${query.toString()}`
);
const { token } = (await response.json()) as { token: string };
const session = await createSession(token);
const cookieValue = await encryptJWECookie(session, '1y');
const next = req.cookies.get('auth-next')?.value;
return NextResponse.redirect(new URL(next ?? '/', origin), {
headers: [
[
'set-cookie',
serialize(userSessionCookieName, cookieValue, {
path: '/',
maxAge: cookieTTL / 1000,
expires: new Date(Date.now() + cookieTTL),
httpOnly: true,
secure: process.env.NODE_ENV !== 'development',
}),
],
// There's currently an issue with adding more than one cookie to a response: https://github.com/vercel/next.js/issues/46579
// the `auth-next` cookie will expire in 10 min anyway
// [
// 'set-cookie',
// serialize('auth-next', '', {
// path: '/api/auth/callback',
// maxAge: 0,
// expires: new Date(),
// httpOnly: true,
// })
// ]
],
});
}

View file

@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server';
import { serialize } from 'cookie';
export const runtime = 'edge';
export async function GET(req: NextRequest) {
const { origin } = req.nextUrl;
const next = req.nextUrl.searchParams.get('next');
const params = new URLSearchParams({
redirectUrl: new URL('/api/auth/callback', origin).toString(),
});
return NextResponse.redirect(
`https://vercel.com/api/vercel-auth?${params.toString()}`,
{
headers: next
? {
'Set-Cookie': serialize('auth-next', next, {
path: '/api/auth/callback',
maxAge: 60 * 60 * 10,
httpOnly: true,
}),
}
: undefined,
}
);
}

View file

@ -0,0 +1,25 @@
import { userSessionCookieName } from '@/lib/session/constants';
import { serialize } from 'cookie';
import { NextRequest, NextResponse } from 'next/server';
export const runtime = 'edge';
export async function GET(req: NextRequest) {
const { origin, searchParams } = req.nextUrl;
const next = searchParams.get('next');
return NextResponse.redirect(new URL(next ?? '/', origin), {
headers: [
[
'set-cookie',
serialize(userSessionCookieName, '', {
path: '/',
maxAge: 0,
expires: new Date(),
httpOnly: true,
secure: process.env.NODE_ENV !== 'development',
}),
],
],
});
}

View file

@ -1,5 +1,5 @@
import { OpenAIStream, openai } from "@/lib/openai"; import { OpenAIStream, openai } from "@/lib/openai";
import { getServerSession } from "next-auth"; import { getServerSession } from "@/lib/session/get-server-session";
export const runtime = "edge"; export const runtime = "edge";

View file

@ -3,8 +3,7 @@ import { prisma } from "@/lib/prisma";
import { Chat } from "../../chat"; import { Chat } from "../../chat";
import { type Metadata } from "next"; import { type Metadata } from "next";
import { getServerSession } from "next-auth"; import { getServerSession } from "@/lib/session/get-server-session";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
export interface ChatPageProps { export interface ChatPageProps {
params: { params: {
@ -30,7 +29,7 @@ export const runtime = "nodejs"; // default
export const preferredRegion = "home"; export const preferredRegion = "home";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function ChatPage({ params }: ChatPageProps) { export default async function ChatPage({ params }: ChatPageProps) {
const session = await getServerSession(authOptions); const session = await getServerSession();
const chat = await prisma.chat.findFirst({ const chat = await prisma.chat.findFirst({
where: { where: {
id: params.id, id: params.id,

View file

@ -1,7 +1,6 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "@/lib/session/get-server-session";
import { Chat } from "./chat"; import { Chat } from "./chat";
import { Sidebar } from "./sidebar"; import { Sidebar } from "./sidebar";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
// Prisma does not support Edge without the Data Proxy currently // Prisma does not support Edge without the Data Proxy currently
export const runtime = "nodejs"; // default export const runtime = "nodejs"; // default
@ -9,7 +8,7 @@ export const preferredRegion = "home";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function IndexPage() { export default async function IndexPage() {
const session = await getServerSession(authOptions); const session = await getServerSession();
return ( return (
<div className="relative flex h-full w-full overflow-hidden"> <div className="relative flex h-full w-full overflow-hidden">
<Sidebar session={session} newChat /> <Sidebar session={session} newChat />

View file

@ -1,16 +1,15 @@
import { Plus } from "lucide-react"; import { DevGPTLogo } from "@/components/ui/devgpt-logo";
import Link from "next/link";
import { Login } from "@/components/ui/login"; import { Login } from "@/components/ui/login";
import { UserMenu } from "@/components/ui/user-menu"; import { UserMenu } from "@/components/ui/user-menu";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { VercelLogo } from "@/components/ui/vercel-logo"; import { type Session } from "@/lib/session/types";
import { SidebarItem } from "./sidebar-item";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { type Session } from "next-auth"; import { Plus } from "lucide-react";
import { DevGPTLogo } from "@/components/ui/devgpt-logo"; import Link from "next/link";
import { SidebarItem } from "./sidebar-item";
export interface SidebarProps { export interface SidebarProps {
session: Session | null; session?: Session;
newChat?: boolean; newChat?: boolean;
} }

View file

@ -4,16 +4,21 @@ import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Moon, Sun } from "lucide-react"; import { Moon, Sun } from "lucide-react";
import { useTransition } from "react";
export function ThemeToggle() { export function ThemeToggle() {
const { setTheme, theme } = useTheme(); const { setTheme, theme } = useTheme();
const [_, startTransition] = useTransition();
return ( return (
<Button <Button
variant="ghost" variant="ghost"
size="xs" size="xs"
className="p-0 leading-4 h-4 font-normal w-full" className="p-0 leading-4 h-4 font-normal w-full"
onClick={() => setTheme(theme === "light" ? "dark" : "light")} onClick={() => {
startTransition(() => {
setTheme(theme === "light" ? "dark" : "light");
});
}}
> >
<span className="flex flex-row justify-between text-xs text-zinc-900 dark:text-zinc-400 w-full"> <span className="flex flex-row justify-between text-xs text-zinc-900 dark:text-zinc-400 w-full">
<span className="">Toggle theme</span> <span className="">Toggle theme</span>

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { ThemeToggle } from "@/components/theme-toggle"; import { ThemeToggle } from "@/components/theme-toggle";
import { type Session } from "next-auth"; import { type Session } from "@/lib/session/types";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import Image from "next/image"; import Image from "next/image";
@ -14,12 +14,12 @@ export function UserMenu({ session }: UserMenuProps) {
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger asChild> <DropdownMenu.Trigger asChild>
<button className="focus:outline-none"> <button className="focus:outline-none">
{session.user?.image ? ( {session.user?.avatar ? (
<Image <Image
width={24} width={24}
height={24} height={24}
className="h-6 w-6 rounded-full select-none ring-zinc-100/10 ring-1 hover:opacity-80 transition-opacity duration-300" className="h-6 w-6 rounded-full select-none ring-zinc-100/10 ring-1 hover:opacity-80 transition-opacity duration-300"
src={session.user?.image ? `${session.user.image}&s=60` : ""} src={session.user?.avatar ? `${session.user.avatar}&s=60` : ""}
alt={session.user.name ?? "Avatar"} alt={session.user.name ?? "Avatar"}
/> />
) : ( ) : (

View file

@ -0,0 +1,25 @@
import { jwtDecrypt, base64url } from 'jose';
export async function decryptJWECookie<T extends string | object = any>(
cookie: string,
secret: string | undefined = process.env.JWE_SECRET
): Promise<T | undefined> {
if (!secret) {
throw new Error('Missing JWE secret');
}
if (typeof cookie !== 'string') return;
try {
const { payload } = await jwtDecrypt(cookie, base64url.decode(secret));
const decoded = payload as T;
// @ts-expect-error jwt field
delete decoded.iat;
// @ts-expect-error jwt field
delete decoded.exp;
return decoded;
} catch {
// Do nothing
}
}

View file

@ -0,0 +1,16 @@
import { EncryptJWT, base64url } from 'jose';
export async function encryptJWECookie<T extends string | object = any>(
payload: T,
expirationTime: string,
secret: string | undefined = process.env.JWE_SECRET
): Promise<string> {
if (!secret) {
throw new Error('Missing JWE secret');
}
return new EncryptJWT(payload as any)
.setExpirationTime(expirationTime)
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
.encrypt(base64url.decode(secret));
}

1
lib/session/constants.ts Normal file
View file

@ -0,0 +1 @@
export const userSessionCookieName = 'user_session';

116
lib/session/create.ts Normal file
View file

@ -0,0 +1,116 @@
import { type Session } from './types';
export async function createSession(vercelToken: string): Promise<Session> {
const [user, teams] = await Promise.all([
fetchUser(vercelToken),
fetchTeams(vercelToken),
]);
const plan = getHighestAccountLevel(teams);
return {
user: {
id: user.uid,
name: user.name,
username: user.username,
email: user.email,
avatar: `https://vercel.com/api/www/avatar/?u=${user.username}`,
plan,
},
vercelToken,
};
}
interface VercelUser {
uid: string;
username: string;
email: string;
name: string;
avatar: string;
}
type BillingStatus = 'active' | 'trialing' | 'overdue' | 'canceled' | 'expired';
export const billingPlans = ['hobby', 'pro', 'enterprise'] as const;
export type BillingPlan = (typeof billingPlans)[number];
export interface Billing {
addons?: string | null;
language?: string | null;
plan: BillingPlan;
// TODO: Make this required once the migration has completed
status?: BillingStatus;
name?: string | null;
overdue?: boolean | null;
period: { start: number; end: number } | null;
purchaseOrder?: string | null;
platform?: 'stripe' | 'stripeTestMode';
email?: string;
trial: { start: number; end: number } | null;
}
export interface VercelTeam {
id: string;
slug: string;
name: string;
avatar?: string;
billing?: Billing;
created?: string;
}
async function fetchUser(token: string) {
const response = await fetch('https://vercel.com/api/user', {
headers: {
Authorization: `Bearer ${token}`,
},
});
const { user } = (await response.json()) as { user: VercelUser };
return user;
}
export async function fetchTeams(token: string) {
const response = await fetch('https://vercel.com/api/teams', {
headers: {
Authorization: `Bearer ${token}`,
},
});
const { teams } = (await response.json()) as { teams: VercelTeam[] };
return teams;
}
export function hasActiveProOrEnterpriseAccount(teams: VercelTeam[]) {
return teams.some(
(team) =>
(team.billing?.plan === 'pro' && team.billing.status === 'active') ||
(team.billing?.plan === 'enterprise' && team.billing.status === 'active')
);
}
export function hasActiveProAccount(teams: VercelTeam[]) {
return teams.some(
(team) => team.billing?.plan === 'pro' && team.billing.status === 'active'
);
}
export function isInTrial(teams: VercelTeam[]) {
return (
!hasActiveProOrEnterpriseAccount(teams) &&
teams.some(
(team) =>
team.billing?.plan === 'pro' && team.billing.status === 'trialing'
)
);
}
export function getHighestAccountLevel(teams: VercelTeam[]): BillingPlan {
const plans = teams
.filter(
(team) => team.billing?.plan && team.billing.status === 'active'
// (team.billing?.plan && team.billing.status === 'trialing')
)
.map((team) => team.billing?.plan);
return plans.includes('enterprise')
? 'enterprise'
: plans.includes('pro')
? 'pro'
: 'hobby';
}

View file

@ -0,0 +1,8 @@
import { cookies } from 'next/headers';
import { userSessionCookieName } from './constants';
import { getSessionFromCookie } from './server';
export async function getServerSession() {
const cookieValue = cookies().get(userSessionCookieName)?.value;
return getSessionFromCookie(cookieValue);
}

23
lib/session/server.ts Normal file
View file

@ -0,0 +1,23 @@
import { type NextApiRequest } from 'next';
import { type NextRequest } from 'next/server';
import { userSessionCookieName } from './constants';
import { decryptJWECookie } from '../cookies/decrypt-jwe';
import { type Session } from './types';
export async function getSessionFromCookie(cookieValue?: string) {
if (!cookieValue) return;
const session = await decryptJWECookie<Session>(cookieValue);
return session;
}
export async function getSessionFromReq(
req: NextApiRequest | NextRequest
): Promise<Session | undefined> {
const cookieValue =
'get' in req.cookies && typeof req.cookies.get === 'function'
? req.cookies.get(userSessionCookieName)?.value
: (req.cookies as Record<string, string>)[userSessionCookieName];
return getSessionFromCookie(cookieValue);
}

13
lib/session/types.ts Normal file
View file

@ -0,0 +1,13 @@
import { BillingPlan } from './create';
export interface Session {
vercelToken: string;
user: {
id: string;
username: string;
email: string;
avatar: string;
name?: string;
plan: BillingPlan;
};
}

View file

@ -27,6 +27,7 @@
"eventsource-parser": "^1.0.0", "eventsource-parser": "^1.0.0",
"focus-trap-react": "^10.1.1", "focus-trap-react": "^10.1.1",
"framer-motion": "^10.12.4", "framer-motion": "^10.12.4",
"jose": "^4.14.4",
"jsonwebtoken": "^9.0.0", "jsonwebtoken": "^9.0.0",
"lucide-react": "0.216.0", "lucide-react": "0.216.0",
"nanoid": "^4.0.2", "nanoid": "^4.0.2",

7
pnpm-lock.yaml generated
View file

@ -37,6 +37,9 @@ dependencies:
framer-motion: framer-motion:
specifier: ^10.12.4 specifier: ^10.12.4
version: 10.12.9(react-dom@18.2.0)(react@18.2.0) version: 10.12.9(react-dom@18.2.0)(react@18.2.0)
jose:
specifier: ^4.14.4
version: 4.14.4
jsonwebtoken: jsonwebtoken:
specifier: ^9.0.0 specifier: ^9.0.0
version: 9.0.0 version: 9.0.0
@ -2864,6 +2867,10 @@ packages:
'@panva/asn1.js': 1.0.0 '@panva/asn1.js': 1.0.0
dev: false dev: false
/jose@4.14.4:
resolution: {integrity: sha512-j8GhLiKmUAh+dsFXlX1aJCbt5KMibuKb+d7j1JaOJG6s2UjX1PQlW+OKB/sD4a/5ZYF4RcmYmLSndOoU3Lt/3g==}
dev: false
/js-sdsl@4.4.0: /js-sdsl@4.4.0:
resolution: {integrity: sha512-FfVSdx6pJ41Oa+CF7RDaFmTnCaFhua+SNYQX74riGOpl96x+2jQCqEfQ2bnXu/5DPCqlRuiqyvTJM0Qjz26IVg==} resolution: {integrity: sha512-FfVSdx6pJ41Oa+CF7RDaFmTnCaFhua+SNYQX74riGOpl96x+2jQCqEfQ2bnXu/5DPCqlRuiqyvTJM0Qjz26IVg==}
dev: true dev: true