From 4505654a0596716ca74ee90397383a2b2e446f2a Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 2 Jan 2024 07:26:20 +0000 Subject: [PATCH] Upgraded to Next Auth v5 --- app/api/account/authorize/route.ts | 9 +- app/api/account/reauthorize/route.ts | 5 -- app/api/account/route.ts | 13 ++- app/api/auth/[...nextauth]/options.ts | 40 --------- app/api/auth/[...nextauth]/route.ts | 7 +- app/api/settings/connections/twitch/route.ts | 52 ----------- app/api/token/bot/route.ts | 1 - app/api/validate/route.ts | 22 ----- app/auth/layout.tsx | 9 ++ app/auth/login/page.tsx | 9 ++ app/settings/layout.tsx | 3 +- app/settings/page.tsx | 5 +- auth.config.ts | 17 ++++ auth.ts | 35 ++++++++ components/auth/card-wrapper.tsx | 40 +++++++++ components/auth/header.tsx | 25 ++++++ components/auth/login-form.tsx | 11 +++ components/auth/social.tsx | 27 ++++++ components/navigation/userprofile.tsx | 21 ++--- data/user.ts | 10 +++ lib/validate-api.ts | 14 ++- middleware.ts | 95 ++++++++------------ prisma/schema.prisma | 29 +++++- routes.ts | 11 +++ 24 files changed, 283 insertions(+), 227 deletions(-) delete mode 100644 app/api/auth/[...nextauth]/options.ts delete mode 100644 app/api/validate/route.ts create mode 100644 app/auth/layout.tsx create mode 100644 app/auth/login/page.tsx create mode 100644 auth.config.ts create mode 100644 auth.ts create mode 100644 components/auth/card-wrapper.tsx create mode 100644 components/auth/header.tsx create mode 100644 components/auth/login-form.tsx create mode 100644 components/auth/social.tsx create mode 100644 data/user.ts create mode 100644 routes.ts diff --git a/app/api/account/authorize/route.ts b/app/api/account/authorize/route.ts index b86c84a..9146688 100644 --- a/app/api/account/authorize/route.ts +++ b/app/api/account/authorize/route.ts @@ -9,9 +9,6 @@ export async function GET(req: Request) { const scope = searchParams.get('scope') as string const state = searchParams.get('state') as string - console.log("CODE:", code) - console.log("SCOPE:", scope) - console.log("STATE:", state) if (!code || !scope || !state) { return new NextResponse("Bad Request", { status: 400 }); } @@ -38,21 +35,17 @@ export async function GET(req: Request) { // Fetch values from token. const { access_token, expires_in, refresh_token, token_type } = token - // console.log("AT", access_token) - // console.log("RT", refresh_token) - // console.log("TT", token_type) if (!access_token || !refresh_token || token_type !== "bearer") { return new NextResponse("Unauthorized", { status: 401 }); } - let info = await axios.get("https://api.twitch.tv/helix/users?login=" + user.username, { + let info = await axios.get("https://api.twitch.tv/helix/users?login=" + user.name, { headers: { "Authorization": "Bearer " + access_token, "Client-Id": process.env.TWITCH_BOT_CLIENT_ID } }) - console.log(info.data.data) const broadcasterId = info.data.data[0]['id'] await db.twitchConnection.create({ diff --git a/app/api/account/reauthorize/route.ts b/app/api/account/reauthorize/route.ts index 586f082..ec797ca 100644 --- a/app/api/account/reauthorize/route.ts +++ b/app/api/account/reauthorize/route.ts @@ -1,7 +1,6 @@ import axios from 'axios' import { db } from "@/lib/db" import { NextResponse } from "next/server"; -import { GET as authorize } from '../authorize/route' export async function GET(req: Request) { try { @@ -11,7 +10,6 @@ export async function GET(req: Request) { id: req.headers.get('x-api-key') as string } }) - if (!key) { return new NextResponse("Forbidden", { status: 403 }); } @@ -46,9 +44,6 @@ export async function GET(req: Request) { // Fetch values from token. const { access_token, expires_in, refresh_token, token_type } = token - // console.log("AT", access_token) - // console.log("RT", refresh_token) - // console.log("TT", token_type) if (!access_token || !refresh_token || token_type !== "bearer") { return new NextResponse("Unauthorized", { status: 401 }); diff --git a/app/api/account/route.ts b/app/api/account/route.ts index ac976c9..9db57cd 100644 --- a/app/api/account/route.ts +++ b/app/api/account/route.ts @@ -1,7 +1,6 @@ import { db } from "@/lib/db" import { NextResponse } from "next/server"; -import { getServerSession } from "next-auth"; -import { generateToken } from "../token/route"; +import { auth } from "@/auth"; import fetchUserUsingAPI from "@/lib/validate-api"; @@ -16,7 +15,7 @@ export async function GET(req: Request) { export async function POST(req: Request) { try { - const session = await getServerSession() + const session = await auth() const user = session?.user?.name if (!user) { return new NextResponse("Internal Error", { status: 401 }) @@ -24,26 +23,26 @@ export async function POST(req: Request) { const exist = await db.user.findFirst({ where: { - username: user.toLowerCase() as string + name: user } }); if (exist) { return NextResponse.json({ id: exist.id, - username: exist.username + username: exist.name }); } const newUser = await db.user.create({ data: { - username: user.toLowerCase() as string, + name: user, } }); return NextResponse.json({ id: newUser.id, - username: newUser.username + username: newUser.name }); } catch (error) { console.log("[ACCOUNT]", error); diff --git a/app/api/auth/[...nextauth]/options.ts b/app/api/auth/[...nextauth]/options.ts deleted file mode 100644 index 59e6986..0000000 --- a/app/api/auth/[...nextauth]/options.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { NextAuthOptions } from "next-auth"; -import TwitchProvider from "next-auth/providers/twitch"; - -export interface TwitchProfile extends Record { - sub: string - preferred_username: string - email: string - picture: string -} - -export const options: NextAuthOptions = { - providers: [ - TwitchProvider({ - clientId: process.env.TWITCH_CLIENT_ID as string, - clientSecret: process.env.TWITCH_CLIENT_SECRET as string, - authorization: { - params: { - scope: "openid user:read:email", - claims: { - id_token: { - email: null, - picture: null, - preferred_username: null, - }, - }, - }, - }, - idToken: true, - profile(profile) { - return { - id: profile.sub, - name: profile.preferred_username, - email: profile.email, - image: profile.picture, - } - }, - }) - ], - secret: process.env.NEXTAUTH_SECRET -} \ No newline at end of file diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index 8fa0a36..70c59d1 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -1,6 +1 @@ -import NextAuth from 'next-auth' -import { options } from './options' - -const handler = NextAuth(options) - -export { handler as GET, handler as POST } \ No newline at end of file +export { GET, POST } from "@/auth" \ No newline at end of file diff --git a/app/api/settings/connections/twitch/route.ts b/app/api/settings/connections/twitch/route.ts index be4d8b8..149fa01 100644 --- a/app/api/settings/connections/twitch/route.ts +++ b/app/api/settings/connections/twitch/route.ts @@ -1,4 +1,3 @@ -import axios from "axios" import { db } from "@/lib/db" import { NextResponse } from "next/server"; import fetchUserUsingAPI from "@/lib/validate-api"; @@ -7,7 +6,6 @@ export async function GET(req: Request) { try { const user = await fetchUserUsingAPI(req) if (!user) { - console.log("TWITCH CONNECT", user) return new NextResponse("Unauthorized", { status: 401 }); } @@ -26,56 +24,6 @@ export async function GET(req: Request) { } }); - return NextResponse.json(connection); - } catch (error) { - console.log("[CONNECTION/TWITCH]", error); - return new NextResponse("Internal Error", { status: 500 }); - } -} - -export async function POST(req: Request) { - try { - const { id, secret } = await req.json(); - const user = await fetchUserUsingAPI(req) - - if (!user) { - return new NextResponse("Unauthorized", { status: 401 }); - } - - let response = null; - try { - response = await axios.post("https://id.twitch.tv/oauth2/token", { - client_id: id, - client_secret: secret, - grant_type: "client_credentials" - }); - console.log(response.data) - } catch (error) { - console.log("[CONNECTIONS/TWITCH/TOKEN]", error); - return; - } - - console.log(user.username) - let info = await axios.get("https://api.twitch.tv/helix/users?login=" + user.username, { - headers: { - "Authorization": "Bearer " + response.data['access_token'], - "Client-Id": id - } - }) - console.log(info.data.data) - const broadcasterId = info.data.data[0]['id'] - const username = info.data.data[0]['login'] - - const connection = await db.twitchConnection.create({ - data: { - id: id, - secret, - userId: user.id as string, - broadcasterId, - username - } - }); - return NextResponse.json(connection); } catch (error) { console.log("[CONNECTION/TWITCH]", error); diff --git a/app/api/token/bot/route.ts b/app/api/token/bot/route.ts index 075293d..c876f7b 100644 --- a/app/api/token/bot/route.ts +++ b/app/api/token/bot/route.ts @@ -14,7 +14,6 @@ export async function GET(req: Request) { userId: user.id } }) - if (!api) { return new NextResponse("Forbidden", { status: 403 }); } diff --git a/app/api/validate/route.ts b/app/api/validate/route.ts deleted file mode 100644 index bdd904d..0000000 --- a/app/api/validate/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { db } from "@/lib/db" -import { NextResponse } from "next/server"; - -export async function GET(req: Request, { params } : { params: { id: string } }) { - try { - let id = req.headers.get('x-api-key') - if (id == null) { - return NextResponse.json(null); - } - - const tokens = await db.apiKey.findFirst({ - where: { - id: id as string - } - }); - - return NextResponse.json(tokens); - } catch (error) { - console.log("[VALIDATE/GET]", error); - return new NextResponse("Internal Error", { status: 500}); - } -} \ No newline at end of file diff --git a/app/auth/layout.tsx b/app/auth/layout.tsx new file mode 100644 index 0000000..1819a92 --- /dev/null +++ b/app/auth/layout.tsx @@ -0,0 +1,9 @@ +const AuthLayout = ({ children } : { children: React.ReactNode }) => { + return ( +
+ {children} +
+ ); +} + +export default AuthLayout; \ No newline at end of file diff --git a/app/auth/login/page.tsx b/app/auth/login/page.tsx new file mode 100644 index 0000000..b1f4e5b --- /dev/null +++ b/app/auth/login/page.tsx @@ -0,0 +1,9 @@ +import { LoginForm } from "@/components/auth/login-form"; + +const LoginPage = () => { + return ( + + ); +} + +export default LoginPage; \ No newline at end of file diff --git a/app/settings/layout.tsx b/app/settings/layout.tsx index 2fa4a6c..3b4bb40 100644 --- a/app/settings/layout.tsx +++ b/app/settings/layout.tsx @@ -7,14 +7,13 @@ const SettingsLayout = async ( { children } : { children:React.ReactNode } ) => { const headersList = headers(); const header_url = headersList.get('x-url') || ""; - console.log("HEADER URL: " + header_url) return (
- + {header_url}
{children}
diff --git a/app/settings/page.tsx b/app/settings/page.tsx index d9169ed..f52d4f5 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -1,8 +1,9 @@ -"use client"; +import { auth } from "@/auth"; -const SettingsPage = () => { +const SettingsPage = async () => { return (
+ {JSON.stringify(await auth())}
); } diff --git a/auth.config.ts b/auth.config.ts new file mode 100644 index 0000000..27e6f19 --- /dev/null +++ b/auth.config.ts @@ -0,0 +1,17 @@ +import Twitch from "next-auth/providers/twitch" + +import type { NextAuthConfig } from "next-auth" + +export default { + providers: [ + Twitch({ + clientId: process.env.TWITCH_CLIENT_ID, + clientSecret: process.env.TWITCH_CLIENT_SECRET, + authorization: { + params: { + scope: "openid user:read:email", + }, + } + }) + ], +} satisfies NextAuthConfig \ No newline at end of file diff --git a/auth.ts b/auth.ts new file mode 100644 index 0000000..1c9ac1b --- /dev/null +++ b/auth.ts @@ -0,0 +1,35 @@ +import NextAuth from "next-auth" +import { PrismaAdapter } from "@auth/prisma-adapter" + +import { db } from "@/lib/db" +import authConfig from "@/auth.config" + +export const { + handlers: { GET, POST }, + auth, + signIn, + signOut, +} = NextAuth({ + events: { + async linkAccount({ user }) { + await db.user.update({ + where: {id: user.id }, + data: { emailVerified: new Date() } + }) + } + }, + callbacks: { + async session({ session, user, token }) { + if (token.sub && session.user) { + session.user.id = token.sub + } + return session + }, + async jwt({ token, user, account, profile, isNewUser }) { + return token + } + }, + adapter: PrismaAdapter(db), + session: { strategy: "jwt" }, + ...authConfig, +}) \ No newline at end of file diff --git a/components/auth/card-wrapper.tsx b/components/auth/card-wrapper.tsx new file mode 100644 index 0000000..d03709e --- /dev/null +++ b/components/auth/card-wrapper.tsx @@ -0,0 +1,40 @@ +"use client" + +import { LoginForm } from "@/components/auth/login-form"; +import React from "react"; +import { + Card, + CardContent, + CardHeader, + CardFooter, + CardTitle +} from "@/components/ui/card"; +import { Header } from "@/components/auth/header"; +import { Social } from "@/components/auth/social"; + + +interface CardWrapperProps { + children: React.ReactNode + headerLabel: string +} + +export const CardWrapper = ({ + children, + headerLabel +}: CardWrapperProps) => { + return ( + + +
+ + + {children} + + + + + + ); +} + +export default CardWrapper \ No newline at end of file diff --git a/components/auth/header.tsx b/components/auth/header.tsx new file mode 100644 index 0000000..11df6b0 --- /dev/null +++ b/components/auth/header.tsx @@ -0,0 +1,25 @@ +import { Poppins } from "next/font/google"; +import { cn } from "@/lib/utils"; + + +const font = Poppins({ + subsets: ["latin"], + weight: ["600"] +}) + +interface HeaderProps { + label: string +} + +export const Header = ({ label }: HeaderProps) => { + return ( +
+

Login

+

+ {label} +

+
+ ) +} \ No newline at end of file diff --git a/components/auth/login-form.tsx b/components/auth/login-form.tsx new file mode 100644 index 0000000..d6c3cb2 --- /dev/null +++ b/components/auth/login-form.tsx @@ -0,0 +1,11 @@ +import { CardWrapper } from "./card-wrapper" + +export const LoginForm = () => { + return ( + + Login Form + + ) +} \ No newline at end of file diff --git a/components/auth/social.tsx b/components/auth/social.tsx new file mode 100644 index 0000000..ab329a5 --- /dev/null +++ b/components/auth/social.tsx @@ -0,0 +1,27 @@ +"use client" + +import { signIn } from "next-auth/react" +import { FaTwitch } from "react-icons/fa" +import { Button } from "@/components/ui/button" +import { DEFAULT_REDIRECT } from "@/routes" + +export const Social = () => { + const onClick = (provider: "twitch") => { + signIn(provider, { + callbackUrl: DEFAULT_REDIRECT, + }) + } + + return ( +
+ +
+ ) +} \ No newline at end of file diff --git a/components/navigation/userprofile.tsx b/components/navigation/userprofile.tsx index a9bd09e..66fa4ce 100644 --- a/components/navigation/userprofile.tsx +++ b/components/navigation/userprofile.tsx @@ -2,40 +2,37 @@ import axios from "axios"; import * as React from 'react'; -import { User } from "@prisma/client"; import { useEffect, useState } from "react"; import { useSession } from "next-auth/react"; -import { usePathname } from 'next/navigation' import { cn } from "@/lib/utils"; const UserProfile = () => { - const { data: session, status } = useSession(); - const [previousUsername, setPreviousUsername] = useState() - const [user, setUser] = useState() - const [loading, setLoading] = useState(true) - const pathname = usePathname() + const { data: session, status } = useSession(); + const [user, setUser] = useState<{ id: string, username: string }>() + + let previousUsername = "" useEffect(() => { if (status !== "authenticated" || previousUsername == session.user?.name) { return } - setPreviousUsername(session.user?.name as string) + previousUsername = session.user?.name || "" if (session.user) { const fetchData = async () => { - var userData: User = (await axios.get("/api/account")).data + var userData = (await axios.get("/api/account")).data setUser(userData) - setLoading(false) + console.log(userData) } fetchData().catch(console.error) - // TODO: check cookies if impersonation is in use. + // TODO: check session if impersonation is in use. } }, [session]) return ( -
+

Logged in as:

{user?.username}

diff --git a/data/user.ts b/data/user.ts new file mode 100644 index 0000000..80dafe2 --- /dev/null +++ b/data/user.ts @@ -0,0 +1,10 @@ +import { db } from "@/lib/db"; + +export const getUserById = async (id: string) => { + try { + const user = await db.user.findUnique({ where: { id }}) + return user; + } catch { + return null; + } +} \ No newline at end of file diff --git a/lib/validate-api.ts b/lib/validate-api.ts index a4041bf..bdc9f4a 100644 --- a/lib/validate-api.ts +++ b/lib/validate-api.ts @@ -1,25 +1,23 @@ -import { getServerSession } from "next-auth"; +import { auth } from "@/auth"; import { db } from "./db"; export default async function fetchUserUsingAPI(req: Request) { - const session = await getServerSession() - console.log("server session:", session) + const session = await auth() if (session) { const user = await db.user.findFirst({ where: { - username: session.user?.name?.toLowerCase() as string + name: session.user?.name } }) return { id: user?.id, - username: user?.username + username: user?.name } } const token = req.headers?.get('x-api-key') - console.log("x-api-key:", token) if (token === null || token === undefined) return null @@ -35,10 +33,8 @@ export default async function fetchUserUsingAPI(req: Request) { } }) - console.log("user:", user) - return { id: user?.id, - username: user?.username + username: user?.name } } \ No newline at end of file diff --git a/middleware.ts b/middleware.ts index b48ff75..7ab851d 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,64 +1,43 @@ -// import { authMiddleware } from "@clerk/nextjs"; -// import { NextResponse } from "next/server"; - -// // This example protects all routes including api/trpc routes -// // Please edit this to allow other routes to be public as needed. -// // See https://clerk.com/docs/references/nextjs/auth-middleware for more information about configuring your middleware -// export default authMiddleware({ -// publicRoutes: ["/api/:path*"], -// ignoredRoutes: ["/api/validate/:path*"], +import authConfig from "@/auth.config" +import NextAuth from "next-auth" -// beforeAuth: async (req) => { -// // if (req.url.startsWith("https://localhost:3000/api") /*&& !req.url.startsWith("https://localhost:3000/api/validate/")*/) { -// // const apiKey = req.headers.get("x-api-key") as string -// // let api = null -// // if (apiKey != null) { -// // console.log("API KEY:", apiKey) -// // api = await fetch("http://localhost:3000/api/validate") -// // } -// // if (api == null) { -// // console.log("Invalid API key attempted") -// // return NextResponse.rewrite( -// // `${req.nextUrl.protocol}//${req.nextUrl.host}`, -// // { -// // status: 401, -// // headers: { -// // "WWW-Authenticate": 'Basic realm="Secure Area"', -// // }, -// // } -// // ); -// // } -// // } +import { + DEFAULT_REDIRECT, + PUBLIC_ROUTES, + AUTH_ROUTES, + API_PREFIX +} from "@/routes" -// return NextResponse.next(); -// } -// }); - -// export const config = { -// matcher: ["/((?!.*\\..*|_next).*)", "/", "/(trpc)(.*)"], -// }; +const { auth } = NextAuth(authConfig) -import { NextResponse } from "next/server"; -import { redirect } from 'next/navigation'; -import { withAuth } from 'next-auth/middleware'; -import { getServerSession } from "next-auth"; +export default auth((req) => { + const isLoggedIn = !!req.auth -export default withAuth( - async function middleware(req) { - const requestHeaders = new Headers(req.headers); - requestHeaders.set('x-url', req.url); + const { nextUrl } = req - return NextResponse.next({ - request: { - // Apply new request headers - headers: requestHeaders, - } - }); - }, - { - callbacks: { - authorized: async ({ req, token }) => - req.nextUrl.pathname?.slice(0, 4) === '/api' || - !!token + const isApiRoute = nextUrl.pathname.startsWith(API_PREFIX) + const isPublicRoute = PUBLIC_ROUTES.includes(nextUrl.pathname) + const isAuthRoute = AUTH_ROUTES.includes(nextUrl.pathname) + + if (isApiRoute) { + return null } -}); \ No newline at end of file + + if (isAuthRoute) { + if (isLoggedIn) { + return Response.redirect(new URL(DEFAULT_REDIRECT, nextUrl)) + } + return null; + } + + if (!isLoggedIn && !isPublicRoute) { + return Response.redirect(new URL("/auth/login", nextUrl)) + } + + return null +}) + +// Optionally, don't invoke Middleware on some paths +export const config = { + matcher: ['/((?!.+\\.[\\w]+$|_next).*)', '/', '/(api|trpc)(.*)'], +} \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 48fad74..9edeaa2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9,10 +9,14 @@ datasource db { } model User { - id String @id @default(uuid()) - username String @unique + id String @id @default(cuid()) + name String? + email String? @unique + emailVerified DateTime? + image String? apiKeys ApiKey[] + accounts Account[] twitchConnections TwitchConnection[] createdProfiles TtsProfile[] profileStatus TtsProfileStatus[] @@ -21,6 +25,25 @@ model User { updatedAt DateTime @updatedAt } +model Account { + id String @id @default(cuid()) + userId String + type String + provider String + providerAccountId String + refresh_token String? @db.Text + access_token String? @db.Text + expires_at Int? + token_type String? + scope String? + id_token String? @db.Text + session_state String? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([provider, providerAccountId]) +} + model ApiKey { id String @id @default(uuid()) label String @@ -88,7 +111,7 @@ model TtsBadgeFilter { model TtsUsernameFilter { username String - white Boolean + tag String profileId String profile TtsProfile @relation(fields: [profileId], references: [id], onDelete: Cascade) diff --git a/routes.ts b/routes.ts new file mode 100644 index 0000000..b2e1cfd --- /dev/null +++ b/routes.ts @@ -0,0 +1,11 @@ +export const PUBLIC_ROUTES = [ + "/" +] + +export const AUTH_ROUTES = [ + "/auth/login", + "/auth/register", +] + +export const API_PREFIX = "/api/auth" +export const DEFAULT_REDIRECT = "/settings" \ No newline at end of file