Move to next-auth (#4)

This commit is contained in:
Jared Palmer 2023-05-22 10:52:33 -04:00 committed by GitHub
commit f14e73ac8f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 114 additions and 344 deletions

View file

@ -1,11 +1,11 @@
"use server"; "use server";
import { auth } from "@/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";
export async function removeChat({ id, path }: { id: string; path: string }) { export async function removeChat({ id, path }: { id: string; path: string }) {
const session = await getServerSession(); const session = await auth();
const userId = session?.user?.email; const userId = session?.user?.email;
if (!userId || !id) { if (!userId || !id) {

View file

@ -0,0 +1,2 @@
export { GET, POST } from "@/auth";
export const runtime = "edge";

View file

@ -1,62 +0,0 @@
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

@ -1,29 +0,0 @@
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

@ -1,25 +0,0 @@
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,10 +1,11 @@
import { auth } from "@/auth";
import { OpenAIStream, openai } from "@/lib/openai"; import { OpenAIStream, openai } from "@/lib/openai";
import { getServerSession } from "@/lib/session/get-server-session";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export async function POST(req: Request) { export const POST = auth(async function POST(req: Request) {
const json = await req.json(); const json = await req.json();
const session = await getServerSession(); // @ts-ignore
console.log(req.auth); // todo fix types
const res = await openai.createChatCompletion({ const res = await openai.createChatCompletion({
model: "gpt-3.5-turbo", model: "gpt-3.5-turbo",
@ -41,4 +42,4 @@ export async function POST(req: Request) {
status: 200, status: 200,
headers: { "Content-Type": "text/event-stream" }, headers: { "Content-Type": "text/event-stream" },
}); });
} });

View file

@ -3,7 +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 "@/lib/session/get-server-session"; import { auth } from "@/auth";
export interface ChatPageProps { export interface ChatPageProps {
params: { params: {
@ -29,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(); const session = await auth();
const chat = await prisma.chat.findFirst({ const chat = await prisma.chat.findFirst({
where: { where: {
id: params.id, id: params.id,

View file

@ -1,6 +1,6 @@
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 { auth } from "@/auth";
// 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
@ -8,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(); const session = await auth();
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

@ -2,7 +2,7 @@ import { NextChatLogo } from "@/components/ui/nextchat-logo";
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 { type Session } from "@/lib/session/types"; import { type Session } from "@auth/nextjs/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
@ -70,7 +70,8 @@ async function SidebarList({ session }: { session?: Session }) {
updatedAt: "desc", updatedAt: "desc",
}, },
}), }),
[session?.user.id || ""], // @ts-ignore
[session?.user?.id || ""],
{ {
revalidate: 3600, revalidate: 3600,
} }

26
auth.ts Normal file
View file

@ -0,0 +1,26 @@
import NextAuth from "@auth/nextjs";
import GitHub from "@auth/nextjs/providers/github";
import { NextResponse } from "next/server";
export const {
handlers: { GET, POST },
auth,
CSRF_experimental,
} = NextAuth({
// @ts-ignore
providers: [GitHub],
session: { strategy: "jwt" },
async authorized({ request, auth }: any) {
const url = request.nextUrl;
if (request.method === "POST") {
const { authToken } = (await request.json()) ?? {};
// If the request has a valid auth token, it is authorized
const valid = true;
if (valid) return true;
return NextResponse.json("Invalid auth token", { status: 401 });
}
// Logged in users are authenticated, otherwise redirect to login page
return !!auth;
},
});

View file

@ -1,14 +1,13 @@
"use client"; "use client";
import { signIn } from "@auth/nextjs/client";
import Link from "next/link";
export function Login() { export function Login() {
return ( return (
<Link <button
className="inline-flex w-full items-center justify-center rounded border border-zinc-800 bg-white h-8 px-4 -my-1.5 text-sm leading-6 tracking-tight text-zinc-900 transition-colors ease-in-out hover:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-50" className="inline-flex w-full items-center justify-center rounded border border-zinc-800 bg-white h-8 px-4 -my-1.5 text-sm leading-6 tracking-tight text-zinc-900 transition-colors ease-in-out hover:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-50"
href="/api/auth/login?next=/?s=1" onClick={() => signIn("github")}
> >
<span className="font-medium">Login</span> <span className="font-medium">Login</span>
</Link> </button>
); );
} }

View file

@ -1,7 +1,9 @@
"use client"; "use client";
import { ThemeToggle } from "@/components/theme-toggle"; import { ThemeToggle } from "@/components/theme-toggle";
import { type Session } from "@/lib/session/types"; import { type Session } from "@auth/nextjs/types";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { signOut } from "@auth/nextjs/client";
import Image from "next/image"; import Image from "next/image";
export interface UserMenuProps { export interface UserMenuProps {
@ -14,12 +16,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?.avatar ? ( {session.user?.image ? (
<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?.avatar ? `${session.user.avatar}&s=60` : ""} src={session.user?.image ? `${session.user.image}&s=60` : ""}
alt={session.user.name ?? "Avatar"} alt={session.user.name ?? "Avatar"}
/> />
) : ( ) : (
@ -81,9 +83,7 @@ export function UserMenu({ session }: UserMenuProps) {
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item <DropdownMenu.Item
className="py-2 px-3 hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors duration-200 cursor-pointer text-xs focus:outline-none" className="py-2 px-3 hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors duration-200 cursor-pointer text-xs focus:outline-none"
onClick={() => { onClick={() => signOut()}
window.location.href = "/api/auth/logout";
}}
> >
Log Out Log Out
</DropdownMenu.Item> </DropdownMenu.Item>
@ -94,4 +94,4 @@ export function UserMenu({ session }: UserMenuProps) {
); );
} }
UserMenu.displayName = "Signout"; UserMenu.displayName = "UserMenu";

View file

@ -1,25 +0,0 @@
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

@ -1,16 +0,0 @@
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));
}

View file

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

View file

@ -1,116 +0,0 @@
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

@ -1,8 +0,0 @@
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);
}

View file

@ -1,23 +0,0 @@
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);
}

View file

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

1
middleware.ts Normal file
View file

@ -0,0 +1 @@
export { auth as middleware } from "./auth";

View file

@ -15,7 +15,8 @@
"format:check": "prettier --check \"**/*.{ts,tsx,mdx}\" --cache" "format:check": "prettier --check \"**/*.{ts,tsx,mdx}\" --cache"
}, },
"dependencies": { "dependencies": {
"@next-auth/prisma-adapter": "^1.0.6", "@auth/nextjs": "0.0.0-manual.030e8328",
"@next-auth/prisma-adapter": "1.0.6",
"@prisma/client": "^4.14.0", "@prisma/client": "^4.14.0",
"@radix-ui/react-dropdown-menu": "^2.0.4", "@radix-ui/react-dropdown-menu": "^2.0.4",
"@vercel/analytics": "^1.0.0", "@vercel/analytics": "^1.0.0",
@ -69,5 +70,10 @@
"prisma": "^4.14.0", "prisma": "^4.14.0",
"tailwindcss": "^3.3.1", "tailwindcss": "^3.3.1",
"typescript": "^4.9.3" "typescript": "^4.9.3"
},
"pnpm": {
"overrides": {
"@auth/core": "0.0.0-manual.527fff6c"
}
} }
} }

54
pnpm-lock.yaml generated
View file

@ -1,8 +1,14 @@
lockfileVersion: '6.0' lockfileVersion: '6.0'
overrides:
'@auth/core': 0.0.0-manual.527fff6c
dependencies: dependencies:
'@auth/nextjs':
specifier: 0.0.0-manual.030e8328
version: 0.0.0-manual.030e8328(next@13.4.3-canary.3)(react@18.2.0)
'@next-auth/prisma-adapter': '@next-auth/prisma-adapter':
specifier: ^1.0.6 specifier: 1.0.6
version: 1.0.6(@prisma/client@4.14.0)(next-auth@4.22.1) version: 1.0.6(@prisma/client@4.14.0)(next-auth@4.22.1)
'@prisma/client': '@prisma/client':
specifier: ^4.14.0 specifier: ^4.14.0
@ -173,6 +179,35 @@ packages:
'@jridgewell/gen-mapping': 0.3.3 '@jridgewell/gen-mapping': 0.3.3
'@jridgewell/trace-mapping': 0.3.18 '@jridgewell/trace-mapping': 0.3.18
/@auth/core@0.0.0-manual.527fff6c:
resolution: {integrity: sha512-NYWuH+6VAAMV05juyarbMm6rCDUGvS5vPFsE+V13NIfbvbwbEZPj3JfcHVv7vMDrUrPtJ4EhTh00Svejp1Moqg==}
peerDependencies:
nodemailer: ^6.8.0
peerDependenciesMeta:
nodemailer:
optional: true
dependencies:
'@panva/hkdf': 1.1.1
cookie: 0.5.0
jose: 4.14.4
oauth4webapi: 2.3.0
preact: 10.11.3
preact-render-to-string: 5.2.3(preact@10.11.3)
dev: false
/@auth/nextjs@0.0.0-manual.030e8328(next@13.4.3-canary.3)(react@18.2.0):
resolution: {integrity: sha512-AAtUl9EVCKqLRneDAq6KfUprWmsBRzsBoQ+8ytz/Nfgs8Uax5CWvCJs2CsnYNk04bXSB4L7eyMJmc1zTzZKaHg==}
peerDependencies:
next: ^13.4.2
react: ^18.2.0
dependencies:
'@auth/core': 0.0.0-manual.527fff6c
next: 13.4.3-canary.3(@babel/core@7.21.8)(react-dom@18.2.0)(react@18.2.0)
react: 18.2.0
transitivePeerDependencies:
- nodemailer
dev: false
/@babel/code-frame@7.21.4: /@babel/code-frame@7.21.4:
resolution: {integrity: sha512-LYvhNKfwWSPpocw8GI7gpK2nq3HSDuEPC/uSYaALSJu9xjsalaaYFOq0Pwt5KmVqwEbZlDu81aLXwBOmD/Fv9g==} resolution: {integrity: sha512-LYvhNKfwWSPpocw8GI7gpK2nq3HSDuEPC/uSYaALSJu9xjsalaaYFOq0Pwt5KmVqwEbZlDu81aLXwBOmD/Fv9g==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@ -3532,6 +3567,10 @@ packages:
path-key: 4.0.0 path-key: 4.0.0
dev: true dev: true
/oauth4webapi@2.3.0:
resolution: {integrity: sha512-JGkb5doGrwzVDuHwgrR4nHJayzN4h59VCed6EW8Tql6iHDfZIabCJvg6wtbn5q6pyB2hZruI3b77Nudvq7NmvA==}
dev: false
/oauth@0.9.15: /oauth@0.9.15:
resolution: {integrity: sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==} resolution: {integrity: sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==}
dev: false dev: false
@ -3824,6 +3863,15 @@ packages:
picocolors: 1.0.0 picocolors: 1.0.0
source-map-js: 1.0.2 source-map-js: 1.0.2
/preact-render-to-string@5.2.3(preact@10.11.3):
resolution: {integrity: sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==}
peerDependencies:
preact: '>=10'
dependencies:
preact: 10.11.3
pretty-format: 3.8.0
dev: false
/preact-render-to-string@5.2.6(preact@10.14.1): /preact-render-to-string@5.2.6(preact@10.14.1):
resolution: {integrity: sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==} resolution: {integrity: sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==}
peerDependencies: peerDependencies:
@ -3833,6 +3881,10 @@ packages:
pretty-format: 3.8.0 pretty-format: 3.8.0
dev: false dev: false
/preact@10.11.3:
resolution: {integrity: sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==}
dev: false
/preact@10.14.1: /preact@10.14.1:
resolution: {integrity: sha512-4XDSnUisk3YFBb3p9WeKeH1mKoxdFUsaXcvxs9wlpYR1wax/TWJVqhwmIWbByX0h7jMEJH6Zc5J6jqc58FKaNQ==} resolution: {integrity: sha512-4XDSnUisk3YFBb3p9WeKeH1mKoxdFUsaXcvxs9wlpYR1wax/TWJVqhwmIWbByX0h7jMEJH6Zc5J6jqc58FKaNQ==}
dev: false dev: false