Upgraded to Next Auth v5

This commit is contained in:
Tom 2024-01-02 07:26:20 +00:00
parent a3352af981
commit 4505654a05
24 changed files with 283 additions and 227 deletions

View File

@ -9,9 +9,6 @@ export async function GET(req: Request) {
const scope = searchParams.get('scope') as string const scope = searchParams.get('scope') as string
const state = searchParams.get('state') 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) { if (!code || !scope || !state) {
return new NextResponse("Bad Request", { status: 400 }); return new NextResponse("Bad Request", { status: 400 });
} }
@ -38,21 +35,17 @@ export async function GET(req: Request) {
// Fetch values from token. // Fetch values from token.
const { access_token, expires_in, refresh_token, token_type } = 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") { if (!access_token || !refresh_token || token_type !== "bearer") {
return new NextResponse("Unauthorized", { status: 401 }); 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: { headers: {
"Authorization": "Bearer " + access_token, "Authorization": "Bearer " + access_token,
"Client-Id": process.env.TWITCH_BOT_CLIENT_ID "Client-Id": process.env.TWITCH_BOT_CLIENT_ID
} }
}) })
console.log(info.data.data)
const broadcasterId = info.data.data[0]['id'] const broadcasterId = info.data.data[0]['id']
await db.twitchConnection.create({ await db.twitchConnection.create({

View File

@ -1,7 +1,6 @@
import axios from 'axios' import axios from 'axios'
import { db } from "@/lib/db" import { db } from "@/lib/db"
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { GET as authorize } from '../authorize/route'
export async function GET(req: Request) { export async function GET(req: Request) {
try { try {
@ -11,7 +10,6 @@ export async function GET(req: Request) {
id: req.headers.get('x-api-key') as string id: req.headers.get('x-api-key') as string
} }
}) })
if (!key) { if (!key) {
return new NextResponse("Forbidden", { status: 403 }); return new NextResponse("Forbidden", { status: 403 });
} }
@ -46,9 +44,6 @@ export async function GET(req: Request) {
// Fetch values from token. // Fetch values from token.
const { access_token, expires_in, refresh_token, token_type } = 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") { if (!access_token || !refresh_token || token_type !== "bearer") {
return new NextResponse("Unauthorized", { status: 401 }); return new NextResponse("Unauthorized", { status: 401 });

View File

@ -1,7 +1,6 @@
import { db } from "@/lib/db" import { db } from "@/lib/db"
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getServerSession } from "next-auth"; import { auth } from "@/auth";
import { generateToken } from "../token/route";
import fetchUserUsingAPI from "@/lib/validate-api"; import fetchUserUsingAPI from "@/lib/validate-api";
@ -16,7 +15,7 @@ export async function GET(req: Request) {
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
const session = await getServerSession() const session = await auth()
const user = session?.user?.name const user = session?.user?.name
if (!user) { if (!user) {
return new NextResponse("Internal Error", { status: 401 }) return new NextResponse("Internal Error", { status: 401 })
@ -24,26 +23,26 @@ export async function POST(req: Request) {
const exist = await db.user.findFirst({ const exist = await db.user.findFirst({
where: { where: {
username: user.toLowerCase() as string name: user
} }
}); });
if (exist) { if (exist) {
return NextResponse.json({ return NextResponse.json({
id: exist.id, id: exist.id,
username: exist.username username: exist.name
}); });
} }
const newUser = await db.user.create({ const newUser = await db.user.create({
data: { data: {
username: user.toLowerCase() as string, name: user,
} }
}); });
return NextResponse.json({ return NextResponse.json({
id: newUser.id, id: newUser.id,
username: newUser.username username: newUser.name
}); });
} catch (error) { } catch (error) {
console.log("[ACCOUNT]", error); console.log("[ACCOUNT]", error);

View File

@ -1,40 +0,0 @@
import type { NextAuthOptions } from "next-auth";
import TwitchProvider from "next-auth/providers/twitch";
export interface TwitchProfile extends Record<string, any> {
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
}

View File

@ -1,6 +1 @@
import NextAuth from 'next-auth' export { GET, POST } from "@/auth"
import { options } from './options'
const handler = NextAuth(options)
export { handler as GET, handler as POST }

View File

@ -1,4 +1,3 @@
import axios from "axios"
import { db } from "@/lib/db" import { db } from "@/lib/db"
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import fetchUserUsingAPI from "@/lib/validate-api"; import fetchUserUsingAPI from "@/lib/validate-api";
@ -7,7 +6,6 @@ export async function GET(req: Request) {
try { try {
const user = await fetchUserUsingAPI(req) const user = await fetchUserUsingAPI(req)
if (!user) { if (!user) {
console.log("TWITCH CONNECT", user)
return new NextResponse("Unauthorized", { status: 401 }); 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); return NextResponse.json(connection);
} catch (error) { } catch (error) {
console.log("[CONNECTION/TWITCH]", error); console.log("[CONNECTION/TWITCH]", error);

View File

@ -14,7 +14,6 @@ export async function GET(req: Request) {
userId: user.id userId: user.id
} }
}) })
if (!api) { if (!api) {
return new NextResponse("Forbidden", { status: 403 }); return new NextResponse("Forbidden", { status: 403 });
} }

View File

@ -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});
}
}

9
app/auth/layout.tsx Normal file
View File

@ -0,0 +1,9 @@
const AuthLayout = ({ children } : { children: React.ReactNode }) => {
return (
<div className="h-full flex items-center justify-center bg-black">
{children}
</div>
);
}
export default AuthLayout;

9
app/auth/login/page.tsx Normal file
View File

@ -0,0 +1,9 @@
import { LoginForm } from "@/components/auth/login-form";
const LoginPage = () => {
return (
<LoginForm />
);
}
export default LoginPage;

View File

@ -7,14 +7,13 @@ const SettingsLayout = async (
{ children } : { children:React.ReactNode } ) => { { children } : { children:React.ReactNode } ) => {
const headersList = headers(); const headersList = headers();
const header_url = headersList.get('x-url') || ""; const header_url = headersList.get('x-url') || "";
console.log("HEADER URL: " + header_url)
return ( return (
<div className="h-full"> <div className="h-full">
<div className={cn("hidden md:flex h-full w-[250px] z-30 flex-col fixed inset-y-0", header_url.endsWith("/settings") && "md:flex h-full w-full z-30 flex-col fixed inset-y-0")}> <div className={cn("hidden md:flex h-full w-[250px] z-30 flex-col fixed inset-y-0", header_url.endsWith("/settings") && "md:flex h-full w-full z-30 flex-col fixed inset-y-0")}>
<SettingsNavigation /> <SettingsNavigation />
</div> </div>
{header_url}
<main className={cn("md:pl-[250px] h-full", header_url.endsWith("/settings") && "hidden")}> <main className={cn("md:pl-[250px] h-full", header_url.endsWith("/settings") && "hidden")}>
{children} {children}
</main> </main>

View File

@ -1,8 +1,9 @@
"use client"; import { auth } from "@/auth";
const SettingsPage = () => { const SettingsPage = async () => {
return ( return (
<div> <div>
{JSON.stringify(await auth())}
</div> </div>
); );
} }

17
auth.config.ts Normal file
View File

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

35
auth.ts Normal file
View File

@ -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,
})

View File

@ -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 (
<Card className="w-[400px] shadow-md bg-white text-black">
<CardHeader>
<Header label={headerLabel} />
</CardHeader>
<CardContent>
{children}
</CardContent>
<CardFooter>
<Social />
</CardFooter>
</Card>
);
}
export default CardWrapper

View File

@ -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 (
<div className="w-full flex flex-col gap-y-4 items-center justify-center">
<h1 className={cn(
"text-3xl font-semibold", font.className
)}>Login</h1>
<p className="text-muted-foreground text-sm">
{label}
</p>
</div>
)
}

View File

@ -0,0 +1,11 @@
import { CardWrapper } from "./card-wrapper"
export const LoginForm = () => {
return (
<CardWrapper
headerLabel="Welcome back"
>
Login Form
</CardWrapper>
)
}

View File

@ -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 (
<div className="flex items-center w-full gap-x-2">
<Button
size="lg"
variant="outline"
className="w-full bg-white hover:bg-purple-500"
onClick={() => onClick("twitch")}
>
<FaTwitch className="h-5 w-5" />
</Button>
</div>
)
}

View File

@ -2,40 +2,37 @@
import axios from "axios"; import axios from "axios";
import * as React from 'react'; import * as React from 'react';
import { User } from "@prisma/client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { usePathname } from 'next/navigation'
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const UserProfile = () => { const UserProfile = () => {
const { data: session, status } = useSession(); const { data: session, status } = useSession();
const [previousUsername, setPreviousUsername] = useState<string>() const [user, setUser] = useState<{ id: string, username: string }>()
const [user, setUser] = useState<User>()
const [loading, setLoading] = useState<boolean>(true) let previousUsername = ""
const pathname = usePathname()
useEffect(() => { useEffect(() => {
if (status !== "authenticated" || previousUsername == session.user?.name) { if (status !== "authenticated" || previousUsername == session.user?.name) {
return return
} }
setPreviousUsername(session.user?.name as string) previousUsername = session.user?.name || ""
if (session.user) { if (session.user) {
const fetchData = async () => { const fetchData = async () => {
var userData: User = (await axios.get("/api/account")).data var userData = (await axios.get("/api/account")).data
setUser(userData) setUser(userData)
setLoading(false) console.log(userData)
} }
fetchData().catch(console.error) fetchData().catch(console.error)
// TODO: check cookies if impersonation is in use. // TODO: check session if impersonation is in use.
} }
}, [session]) }, [session])
return ( return (
<div className={cn("px-10 py-6 rounded-md bg-blue-300 overflow-hidden wrap", loading && "hidden")}> <div className={cn("px-10 py-6 rounded-md bg-blue-300 overflow-hidden wrap", user == null && "hidden")}>
<p className="text-xs text-gray-400">Logged in as:</p> <p className="text-xs text-gray-400">Logged in as:</p>
<p>{user?.username}</p> <p>{user?.username}</p>
</div> </div>

10
data/user.ts Normal file
View File

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

View File

@ -1,25 +1,23 @@
import { getServerSession } from "next-auth"; import { auth } from "@/auth";
import { db } from "./db"; import { db } from "./db";
export default async function fetchUserUsingAPI(req: Request) { export default async function fetchUserUsingAPI(req: Request) {
const session = await getServerSession() const session = await auth()
console.log("server session:", session)
if (session) { if (session) {
const user = await db.user.findFirst({ const user = await db.user.findFirst({
where: { where: {
username: session.user?.name?.toLowerCase() as string name: session.user?.name
} }
}) })
return { return {
id: user?.id, id: user?.id,
username: user?.username username: user?.name
} }
} }
const token = req.headers?.get('x-api-key') const token = req.headers?.get('x-api-key')
console.log("x-api-key:", token)
if (token === null || token === undefined) if (token === null || token === undefined)
return null return null
@ -35,10 +33,8 @@ export default async function fetchUserUsingAPI(req: Request) {
} }
}) })
console.log("user:", user)
return { return {
id: user?.id, id: user?.id,
username: user?.username username: user?.name
} }
} }

View File

@ -1,64 +1,43 @@
// import { authMiddleware } from "@clerk/nextjs"; import authConfig from "@/auth.config"
// import { NextResponse } from "next/server"; import NextAuth from "next-auth"
// // 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*"],
// beforeAuth: async (req) => { import {
// // if (req.url.startsWith("https://localhost:3000/api") /*&& !req.url.startsWith("https://localhost:3000/api/validate/")*/) { DEFAULT_REDIRECT,
// // const apiKey = req.headers.get("x-api-key") as string PUBLIC_ROUTES,
// // let api = null AUTH_ROUTES,
// // if (apiKey != null) { API_PREFIX
// // console.log("API KEY:", apiKey) } from "@/routes"
// // 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"',
// // },
// // }
// // );
// // }
// // }
// return NextResponse.next(); const { auth } = NextAuth(authConfig)
// }
// });
// export const config = {
// matcher: ["/((?!.*\\..*|_next).*)", "/", "/(trpc)(.*)"],
// };
import { NextResponse } from "next/server"; export default auth((req) => {
import { redirect } from 'next/navigation'; const isLoggedIn = !!req.auth
import { withAuth } from 'next-auth/middleware';
import { getServerSession } from "next-auth";
export default withAuth( const { nextUrl } = req
async function middleware(req) {
const requestHeaders = new Headers(req.headers);
requestHeaders.set('x-url', req.url);
return NextResponse.next({ const isApiRoute = nextUrl.pathname.startsWith(API_PREFIX)
request: { const isPublicRoute = PUBLIC_ROUTES.includes(nextUrl.pathname)
// Apply new request headers const isAuthRoute = AUTH_ROUTES.includes(nextUrl.pathname)
headers: requestHeaders,
} if (isApiRoute) {
}); return null
},
{
callbacks: {
authorized: async ({ req, token }) =>
req.nextUrl.pathname?.slice(0, 4) === '/api' ||
!!token
} }
});
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)(.*)'],
}

View File

@ -9,10 +9,14 @@ datasource db {
} }
model User { model User {
id String @id @default(uuid()) id String @id @default(cuid())
username String @unique name String?
email String? @unique
emailVerified DateTime?
image String?
apiKeys ApiKey[] apiKeys ApiKey[]
accounts Account[]
twitchConnections TwitchConnection[] twitchConnections TwitchConnection[]
createdProfiles TtsProfile[] createdProfiles TtsProfile[]
profileStatus TtsProfileStatus[] profileStatus TtsProfileStatus[]
@ -21,6 +25,25 @@ model User {
updatedAt DateTime @updatedAt 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 { model ApiKey {
id String @id @default(uuid()) id String @id @default(uuid())
label String label String
@ -88,7 +111,7 @@ model TtsBadgeFilter {
model TtsUsernameFilter { model TtsUsernameFilter {
username String username String
white Boolean tag String
profileId String profileId String
profile TtsProfile @relation(fields: [profileId], references: [id], onDelete: Cascade) profile TtsProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)

11
routes.ts Normal file
View File

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