Skip to content

Commit

Permalink
feat: auth rework (#1581)
Browse files Browse the repository at this point in the history
* feat: use `https` in development

* feat: use httpOnly cookie for authenticated requests
  • Loading branch information
BrickheadJohnny authored Dec 10, 2024
1 parent 81d3c38 commit da7220f
Show file tree
Hide file tree
Showing 17 changed files with 91 additions and 159 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,5 @@ bun.lockb
/playwright/.cache/
/playwright/.auth/
/playwright/results

certificates
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
52 changes: 0 additions & 52 deletions src/actions/auth.ts

This file was deleted.

5 changes: 0 additions & 5 deletions src/app/(dashboard)/[guildUrlName]/components/JoinButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand All @@ -45,7 +41,6 @@ export const JoinButton = () => {
method: "post",
maxRetryCount: 0,
headers: {
"x-auth-token": token,
"content-type": "application/json",
},
});
Expand Down
5 changes: 0 additions & 5 deletions src/app/(dashboard)/[guildUrlName]/components/JoinGuild.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
Expand All @@ -34,7 +30,6 @@ export const JoinGuild = () => {
method: "post",
maxRetryCount: 0,
headers: {
"x-auth-token": token,
"content-type": "application/json",
},
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down
7 changes: 4 additions & 3 deletions src/app/(dashboard)/explorer/fetchers.ts
Original file line number Diff line number Diff line change
@@ -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<PaginatedResponse<Schemas["Guild"]>>(
`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}}`,
);
};

Expand Down
4 changes: 2 additions & 2 deletions src/app/(dashboard)/explorer/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<HydrationBoundary state={dehydrate(queryClient)}>
Expand Down
3 changes: 2 additions & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -27,7 +28,7 @@ const RootLayout = ({
<body className={cn(dystopian.variable)}>
<PreloadResources />
<Providers>
{children}
<PrefetchUserBoundary>{children}</PrefetchUserBoundary>

<SignInDialog />
<Toaster />
Expand Down
18 changes: 10 additions & 8 deletions src/components/AuthBoundary.tsx
Original file line number Diff line number Diff line change
@@ -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;
};
15 changes: 15 additions & 0 deletions src/components/PrefetchUserBoundary.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<HydrationBoundary state={dehydrate(queryClient)}>
{children}
</HydrationBoundary>
);
};
24 changes: 21 additions & 3 deletions src/components/SignInDialog.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -21,7 +21,6 @@ import {
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from "./ui/ResponsiveDialog";

const CUSTOM_CONNECTOR_ICONS = {
"com.brave.wallet": "/walletLogos/brave.svg",
walletConnect: "/walletLogos/walletconnect.svg",
Expand Down Expand Up @@ -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) => {
Expand Down
27 changes: 23 additions & 4 deletions src/components/SignOutButton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Button
variant="ghost"
leftIcon={<SignOut weight="bold" />}
onClick={() => signOut(pathname)}
onClick={() => signOut()}
isLoading={isPending}
>
Sign out
</Button>
Expand Down
24 changes: 10 additions & 14 deletions src/lib/fetchGuildApi.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -71,30 +70,27 @@ export const fetchGuildApi = async <Data = object, Error = ErrorLike>(
}
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");
Expand Down
7 changes: 1 addition & 6 deletions src/lib/fetchers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { fetchGuildApiData } from "@/lib/fetchGuildApi";
import { resolveIdLikeRequest } from "@/lib/resolveIdLikeRequest";
import { tryGetParsedToken } from "@/lib/token";
import type {
Entity,
EntitySchema,
Expand All @@ -26,11 +25,7 @@ export const fetchEntity = async <T extends Entity, Error = ErrorLike>({
};

export const fetchUser = async () => {
const { userId } = await tryGetParsedToken();
return fetchEntity({
entity: "user",
idLike: userId,
});
return fetchGuildApiData<Schemas["User"]>("auth/me");
};

export const fetchGuildLeave = async ({ guildId }: { guildId: string }) => {
Expand Down
Loading

0 comments on commit da7220f

Please sign in to comment.