From da7220f192bec9079f85580aec1bbe4c1faeae65 Mon Sep 17 00:00:00 2001 From: BrickheadJohnny <92519134+BrickheadJohnny@users.noreply.github.com> Date: Tue, 10 Dec 2024 16:50:55 +0100 Subject: [PATCH] feat: auth rework (#1581) * feat: use `https` in development * feat: use httpOnly cookie for authenticated requests --- .gitignore | 2 + package.json | 3 +- src/actions/auth.ts | 52 ------------------- .../[guildUrlName]/components/JoinButton.tsx | 5 -- .../[guildUrlName]/components/JoinGuild.tsx | 5 -- .../components/CreateGuildButton.tsx | 6 --- src/app/(dashboard)/explorer/fetchers.ts | 7 +-- src/app/(dashboard)/explorer/layout.tsx | 4 +- src/app/layout.tsx | 3 +- src/components/AuthBoundary.tsx | 18 ++++--- src/components/PrefetchUserBoundary.tsx | 15 ++++++ src/components/SignInDialog.tsx | 24 +++++++-- src/components/SignOutButton.tsx | 27 ++++++++-- src/lib/fetchGuildApi.ts | 24 ++++----- src/lib/fetchers.ts | 7 +-- src/lib/getCookieClientSide.ts | 21 -------- src/lib/token.ts | 27 ---------- 17 files changed, 91 insertions(+), 159 deletions(-) delete mode 100644 src/actions/auth.ts create mode 100644 src/components/PrefetchUserBoundary.tsx delete mode 100644 src/lib/getCookieClientSide.ts delete mode 100644 src/lib/token.ts diff --git a/.gitignore b/.gitignore index 1fffb8e724..d1a75623fe 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,5 @@ bun.lockb /playwright/.cache/ /playwright/.auth/ /playwright/results + +certificates \ No newline at end of file diff --git a/package.json b/package.json index 624f2ce3a3..883536d381 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "prepare": "husky", - "dev": "next dev --turbo", + "dev": "next dev --turbo --experimental-https", "build": "next build", "start": "next start", "type-check": "tsc --pretty --noEmit --incremental false", @@ -41,7 +41,6 @@ "event-source-plus": "^0.1.8", "foxact": "^0.2.43", "jotai": "^2.10.3", - "jwt-decode": "^4.0.0", "mini-svg-data-uri": "^1.4.4", "next": "15.0.3", "next-themes": "^0.4.4", diff --git a/src/actions/auth.ts b/src/actions/auth.ts deleted file mode 100644 index 8ef8f78b97..0000000000 --- a/src/actions/auth.ts +++ /dev/null @@ -1,52 +0,0 @@ -"use server"; - -import { associatedGuildsOption } from "@/app/(dashboard)/explorer/options"; -import { GUILD_AUTH_COOKIE_NAME } from "@/config/constants"; -import { fetchGuildApi } from "@/lib/fetchGuildApi"; -import { getQueryClient } from "@/lib/getQueryClient"; -import { userOptions } from "@/lib/options"; -import { authSchema, tokenSchema } from "@/lib/schemas/user"; -import { jwtDecode } from "jwt-decode"; -import { cookies } from "next/headers"; -import { redirect } from "next/navigation"; - -export const signIn = async ({ - message, - signature, -}: { - message: string; - signature: string; -}) => { - const cookieStore = await cookies(); - - const requestInit = { - method: "POST", - body: JSON.stringify({ - message, - signature, - }), - } satisfies RequestInit; - - const signInRes = await fetchGuildApi("auth/siwe/login", requestInit); - let json = signInRes.data; - if (signInRes.response.status === 401) { - const registerRes = await fetchGuildApi("auth/siwe/register", requestInit); - json = registerRes.data; - } - const authData = authSchema.parse(json); - const { exp } = tokenSchema.parse(jwtDecode(authData.token)); - - cookieStore.set(GUILD_AUTH_COOKIE_NAME, authData.token, { - expires: new Date(exp * 1000), - }); - return authData; -}; - -export const signOut = async (redirectTo?: string) => { - const cookieStore = await cookies(); - cookieStore.delete(GUILD_AUTH_COOKIE_NAME); - const queryClient = getQueryClient(); - queryClient.removeQueries(associatedGuildsOption()); - queryClient.removeQueries(userOptions()); - redirect(redirectTo ?? "/explorer"); -}; diff --git a/src/app/(dashboard)/[guildUrlName]/components/JoinButton.tsx b/src/app/(dashboard)/[guildUrlName]/components/JoinButton.tsx index 2468544274..0b0554aef2 100644 --- a/src/app/(dashboard)/[guildUrlName]/components/JoinButton.tsx +++ b/src/app/(dashboard)/[guildUrlName]/components/JoinButton.tsx @@ -6,10 +6,8 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/Tooltip"; -import { GUILD_AUTH_COOKIE_NAME } from "@/config/constants"; import { env } from "@/lib/env"; import { fetchGuildLeave } from "@/lib/fetchers"; -import { getCookieClientSide } from "@/lib/getCookieClientSide"; import { guildOptions, userOptions } from "@/lib/options"; import type { Schemas } from "@guildxyz/types"; import { CheckCircle, SignOut } from "@phosphor-icons/react/dist/ssr"; @@ -34,8 +32,6 @@ export const JoinButton = () => { const joinMutation = useMutation({ mutationFn: async () => { - //TODO: Handle error here, throw error in funciton if needed - const token = getCookieClientSide(GUILD_AUTH_COOKIE_NAME)!; const url = new URL( `api/guild/${guild.data.id}/join`, env.NEXT_PUBLIC_API, @@ -45,7 +41,6 @@ export const JoinButton = () => { method: "post", maxRetryCount: 0, headers: { - "x-auth-token": token, "content-type": "application/json", }, }); diff --git a/src/app/(dashboard)/[guildUrlName]/components/JoinGuild.tsx b/src/app/(dashboard)/[guildUrlName]/components/JoinGuild.tsx index 5bc90341c1..b4737cf9f6 100644 --- a/src/app/(dashboard)/[guildUrlName]/components/JoinGuild.tsx +++ b/src/app/(dashboard)/[guildUrlName]/components/JoinGuild.tsx @@ -1,9 +1,7 @@ "use client"; import { Button } from "@/components/ui/Button"; -import { GUILD_AUTH_COOKIE_NAME } from "@/config/constants"; import { env } from "@/lib/env"; -import { getCookieClientSide } from "@/lib/getCookieClientSide"; import { guildOptions, userOptions } from "@/lib/options"; import type { Schemas } from "@guildxyz/types"; import { CheckCircle } from "@phosphor-icons/react/dist/ssr"; @@ -23,8 +21,6 @@ export const JoinGuild = () => { const { mutate, isPending } = useMutation({ mutationFn: async () => { - //TODO: Handle error here, throw error in funciton if needed - const token = getCookieClientSide(GUILD_AUTH_COOKIE_NAME)!; const url = new URL( `api/guild/${guild.data.id}/join`, env.NEXT_PUBLIC_API, @@ -34,7 +30,6 @@ export const JoinGuild = () => { method: "post", maxRetryCount: 0, headers: { - "x-auth-token": token, "content-type": "application/json", }, }); diff --git a/src/app/(dashboard)/create-guild/components/CreateGuildButton.tsx b/src/app/(dashboard)/create-guild/components/CreateGuildButton.tsx index 441b0c21c9..9c6d757eb3 100644 --- a/src/app/(dashboard)/create-guild/components/CreateGuildButton.tsx +++ b/src/app/(dashboard)/create-guild/components/CreateGuildButton.tsx @@ -2,9 +2,7 @@ import { useConfetti } from "@/components/ConfettiProvider"; import { Button } from "@/components/ui/Button"; -import { GUILD_AUTH_COOKIE_NAME } from "@/config/constants"; import { fetchGuildApiData } from "@/lib/fetchGuildApi"; -import { getCookieClientSide } from "@/lib/getCookieClientSide"; import type { CreateGuildForm, Guild } from "@/lib/schemas/guild"; import { CheckCircle, XCircle } from "@phosphor-icons/react/dist/ssr"; import { useMutation } from "@tanstack/react-query"; @@ -21,10 +19,6 @@ const CreateGuildButton = () => { const { mutate: onSubmit, isPending } = useMutation({ mutationFn: async (data: CreateGuildForm) => { - const token = getCookieClientSide(GUILD_AUTH_COOKIE_NAME); - - if (!token) throw new Error("Unauthorized"); // TODO: custom errors? - const guild = { ...data, contact: undefined, diff --git a/src/app/(dashboard)/explorer/fetchers.ts b/src/app/(dashboard)/explorer/fetchers.ts index e498d043f6..335db2db96 100644 --- a/src/app/(dashboard)/explorer/fetchers.ts +++ b/src/app/(dashboard)/explorer/fetchers.ts @@ -1,14 +1,15 @@ import { fetchGuildApiData } from "@/lib/fetchGuildApi"; +import { userOptions } from "@/lib/options"; import type { Leaderboard } from "@/lib/schemas/leaderboard"; -import { tryGetParsedToken } from "@/lib/token"; import type { PaginatedResponse } from "@/lib/types"; import type { Schemas } from "@guildxyz/types"; +import { useQuery } from "@tanstack/react-query"; import { PAGE_SIZE } from "./constants"; export const fetchAssociatedGuilds = async () => { - const { userId } = await tryGetParsedToken(); + const { data: user } = useQuery(userOptions()); return fetchGuildApiData>( - `guild/search?page=1&pageSize=${Number.MAX_SAFE_INTEGER}&sortBy=name&reverse=false&customQuery=@owner:{${userId}}`, + `guild/search?page=1&pageSize=${Number.MAX_SAFE_INTEGER}&sortBy=name&reverse=false&customQuery=@owner:{${user?.id}}`, ); }; diff --git a/src/app/(dashboard)/explorer/layout.tsx b/src/app/(dashboard)/explorer/layout.tsx index 2ba50a5015..70965bcbe9 100644 --- a/src/app/(dashboard)/explorer/layout.tsx +++ b/src/app/(dashboard)/explorer/layout.tsx @@ -1,12 +1,12 @@ import { getQueryClient } from "@/lib/getQueryClient"; import { HydrationBoundary, dehydrate } from "@tanstack/react-query"; import type { PropsWithChildren } from "react"; -import { guildSearchOptions } from "./options"; +import { associatedGuildsOption, guildSearchOptions } from "./options"; const ExplorerLayout = async ({ children }: PropsWithChildren) => { const queryClient = getQueryClient(); await queryClient.prefetchInfiniteQuery(guildSearchOptions({})); - // await queryClient.prefetchQuery(associatedGuildsOption()); + await queryClient.prefetchQuery(associatedGuildsOption()); return ( diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 94c5da2364..1ac31cc799 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import "@/styles/globals.css"; +import { PrefetchUserBoundary } from "@/components/PrefetchUserBoundary"; import { PreloadResources } from "@/components/PreloadResources"; import { Providers } from "@/components/Providers"; import { SignInDialog } from "@/components/SignInDialog"; @@ -27,7 +28,7 @@ const RootLayout = ({ - {children} + {children} diff --git a/src/components/AuthBoundary.tsx b/src/components/AuthBoundary.tsx index d90ec5af4e..a9103dfca1 100644 --- a/src/components/AuthBoundary.tsx +++ b/src/components/AuthBoundary.tsx @@ -1,16 +1,18 @@ -import { tryGetToken } from "@/lib/token"; +"use client"; -export const AuthBoundary = async ({ +import { userOptions } from "@/lib/options"; +import { useQuery } from "@tanstack/react-query"; + +export const AuthBoundary = ({ fallback, children, }: Readonly<{ fallback: React.ReactNode; children: React.ReactNode; }>) => { - try { - await tryGetToken(); - return <>{children}; - } catch { - return <>{fallback}; - } + const { data: user } = useQuery(userOptions()); + + if (user?.id) return children; + + return fallback; }; diff --git a/src/components/PrefetchUserBoundary.tsx b/src/components/PrefetchUserBoundary.tsx new file mode 100644 index 0000000000..2a360fe7aa --- /dev/null +++ b/src/components/PrefetchUserBoundary.tsx @@ -0,0 +1,15 @@ +import { getQueryClient } from "@/lib/getQueryClient"; +import { userOptions } from "@/lib/options"; +import { HydrationBoundary, dehydrate } from "@tanstack/react-query"; +import type { PropsWithChildren } from "react"; + +export const PrefetchUserBoundary = async ({ children }: PropsWithChildren) => { + const queryClient = getQueryClient(); + await queryClient.prefetchQuery(userOptions()); + + return ( + + {children} + + ); +}; diff --git a/src/components/SignInDialog.tsx b/src/components/SignInDialog.tsx index 740745b7ad..e921c983bc 100644 --- a/src/components/SignInDialog.tsx +++ b/src/components/SignInDialog.tsx @@ -1,8 +1,8 @@ "use client"; -import { signIn } from "@/actions/auth"; import { signInDialogOpenAtom } from "@/config/atoms"; import { fetchGuildApi } from "@/lib/fetchGuildApi"; +import { authSchema } from "@/lib/schemas/user"; import { SignIn, User, Wallet, XCircle } from "@phosphor-icons/react/dist/ssr"; import { DialogDescription } from "@radix-ui/react-dialog"; import { useMutation } from "@tanstack/react-query"; @@ -21,7 +21,6 @@ import { ResponsiveDialogHeader, ResponsiveDialogTitle, } from "./ui/ResponsiveDialog"; - const CUSTOM_CONNECTOR_ICONS = { "com.brave.wallet": "/walletLogos/brave.svg", walletConnect: "/walletLogos/walletconnect.svg", @@ -155,7 +154,26 @@ const SignInWithEthereum = () => { const signature = await signMessageAsync({ message }); - return signIn({ message, signature }); + const requestInit = { + method: "POST", + body: JSON.stringify({ + message, + signature, + }), + } satisfies RequestInit; + + const signInRes = await fetchGuildApi("auth/siwe/login", requestInit); + let json = signInRes.data; + if (signInRes.response.status === 401) { + const registerRes = await fetchGuildApi( + "auth/siwe/register", + requestInit, + ); + json = registerRes.data; + } + const authData = authSchema.parse(json); + + return authData; }, onSuccess: () => setSignInDialogOpen(false), onError: (error) => { diff --git a/src/components/SignOutButton.tsx b/src/components/SignOutButton.tsx index 38540a6e67..7d7c77a84c 100644 --- a/src/components/SignOutButton.tsx +++ b/src/components/SignOutButton.tsx @@ -1,17 +1,36 @@ "use client"; -import { signOut } from "@/actions/auth"; +import { associatedGuildsOption } from "@/app/(dashboard)/explorer/options"; +import { fetchGuildApi } from "@/lib/fetchGuildApi"; +import { userOptions } from "@/lib/options"; import { SignOut } from "@phosphor-icons/react/dist/ssr"; -import { usePathname } from "next/navigation"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Button } from "./ui/Button"; export const SignOutButton = () => { - const pathname = usePathname(); + const queryClient = useQueryClient(); + + const { mutate: signOut, isPending } = useMutation({ + mutationFn: () => + fetchGuildApi("auth/logout", { + method: "POST", + }), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [userOptions().queryKey], + }); + queryClient.invalidateQueries({ + queryKey: associatedGuildsOption().queryKey, + }); + }, + }); + return ( diff --git a/src/lib/fetchGuildApi.ts b/src/lib/fetchGuildApi.ts index 1449d0a650..d1e0d7cfad 100644 --- a/src/lib/fetchGuildApi.ts +++ b/src/lib/fetchGuildApi.ts @@ -1,5 +1,4 @@ -import { signOut } from "@/actions/auth"; -import { tryGetToken } from "@/lib/token"; +import { isServer } from "@tanstack/react-query"; import { env } from "./env"; import type { ErrorLike } from "./types"; @@ -71,30 +70,27 @@ export const fetchGuildApi = async ( } const url = new URL(`api/${pathname}`, env.NEXT_PUBLIC_API); - let token: string | undefined; - try { - token = await tryGetToken(); - } catch (_) {} - const headers = new Headers(requestInit?.headers); - if (token) { - headers.set("X-Auth-Token", token); - } + if (requestInit?.body instanceof FormData) { headers.set("Content-Type", "multipart/form-data"); } else if (requestInit?.body) { headers.set("Content-Type", "application/json"); } + // Next.js won't include cookies automatically, so we include them manually on the server + if (isServer) { + const { cookies } = await import("next/headers"); + const cookiesHeader = (await cookies()).toString(); + headers.set("cookie", cookiesHeader); + } + const response = await fetch(url, { ...requestInit, headers, + credentials: "include", }); - if (response.status === 401) { - signOut(); - } - const contentType = response.headers.get("content-type"); if (!contentType?.includes("application/json")) { throw new Error("Guild API failed to respond with json"); diff --git a/src/lib/fetchers.ts b/src/lib/fetchers.ts index 5cd1925426..0471d87c30 100644 --- a/src/lib/fetchers.ts +++ b/src/lib/fetchers.ts @@ -1,6 +1,5 @@ import { fetchGuildApiData } from "@/lib/fetchGuildApi"; import { resolveIdLikeRequest } from "@/lib/resolveIdLikeRequest"; -import { tryGetParsedToken } from "@/lib/token"; import type { Entity, EntitySchema, @@ -26,11 +25,7 @@ export const fetchEntity = async ({ }; export const fetchUser = async () => { - const { userId } = await tryGetParsedToken(); - return fetchEntity({ - entity: "user", - idLike: userId, - }); + return fetchGuildApiData("auth/me"); }; export const fetchGuildLeave = async ({ guildId }: { guildId: string }) => { diff --git a/src/lib/getCookieClientSide.ts b/src/lib/getCookieClientSide.ts deleted file mode 100644 index 40ca44e1eb..0000000000 --- a/src/lib/getCookieClientSide.ts +++ /dev/null @@ -1,21 +0,0 @@ -"use client"; - -import { GUILD_AUTH_COOKIE_NAME } from "@/config/constants"; - -export const getCookieClientSide = (name: string) => { - const decodedCookie = decodeURIComponent(document.cookie); - const cookiesArray = decodedCookie.split(";"); - - for (let i = 0; i < cookiesArray.length; i++) { - const [cookieName, cookieValue] = cookiesArray[i] - .split("=") - .map((v) => v.trim()); - if (cookieName === name) return cookieValue; - } - - return undefined; -}; - -export const getTokenClientSide = () => { - return getCookieClientSide(GUILD_AUTH_COOKIE_NAME); -}; diff --git a/src/lib/token.ts b/src/lib/token.ts deleted file mode 100644 index a9bc0ff87d..0000000000 --- a/src/lib/token.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { GUILD_AUTH_COOKIE_NAME } from "@/config/constants"; -import { getTokenClientSide } from "@/lib/getCookieClientSide"; -import { tokenSchema } from "@/lib/schemas/user"; -import { isServer } from "@tanstack/react-query"; -import { jwtDecode } from "jwt-decode"; - -export const tryGetToken = async () => { - let token: string | undefined; - if (isServer) { - const { cookies } = await import("next/headers"); - token = (await cookies()).get(GUILD_AUTH_COOKIE_NAME)?.value; - } else { - token = getTokenClientSide(); - } - - if (!token) { - throw new Error( - "Failed to retrieve JWT token on auth request initialization.", - ); - } - return token; -}; - -export const tryGetParsedToken = async () => { - const token = await tryGetToken(); - return tokenSchema.parse(jwtDecode(token)); -};