Skip to content

Commit

Permalink
feature: Web3 Authentication Integration (#53)
Browse files Browse the repository at this point in the history
  • Loading branch information
b0rza authored Aug 27, 2024
1 parent 7be6a1c commit 0166859
Show file tree
Hide file tree
Showing 40 changed files with 672 additions and 108 deletions.
4 changes: 3 additions & 1 deletion apps/platform/.env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
NEXT_PUBLIC_COMETH_API_KEY=your-public-cometh-api-key


NEXT_PUBLIC_GOOGLE_API_KEY=your-google-api-key

NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=your-nextauth-secret
7 changes: 6 additions & 1 deletion apps/platform/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,17 @@
"dependencies": {
"@allo/kit": "workspace:*",
"@muqa/db": "workspace:*",
"@next-auth/prisma-adapter": "^1.0.7",
"@react-google-maps/api": "^2.19.3",
"axios": "^1.7.4",
"ethers": "^6.13.2",
"next": "14.2.2",
"next-auth": "^4.24.7",
"next-intl": "^3.17.2",
"react": "18",
"react-dom": "18",
"wagmi": "2.9.0"
"wagmi": "2.9.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@muqa/eslint-config": "workspace:*",
Expand Down
13 changes: 12 additions & 1 deletion apps/platform/src/app/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,25 @@ import Navigation from '@/app/components/Navigation';
import LanguageSwitcher from '@/app/components/LanguageSwitcher';

import { CodaFormProjectLink } from '@/app/config/config';

import { MuqaConnectButton } from './components/MuqaConnectButton';
import { useSession } from 'next-auth/react';

export default function Header() {
const [isOpen, setIsOpen] = useState(false);
const { data: session } = useSession();

const t = useTranslations('navigation');

const toggleMenu = () => {
setIsOpen(!isOpen);
};

if (session) {
console.log(`Signed in as ${session.user?.name}`);
} else {
console.log('Not signed in');
}

return (
<header className='border-b border-borderGray bg-white'>
<Container className='mx-auto flex items-center justify-between px-5 py-5'>
Expand All @@ -37,6 +45,9 @@ export default function Header() {
<div className='mr-4 hidden md:block'>
<LanguageSwitcher screen='desktop' />
</div>
{process.env.NODE_ENV === 'development' && (
<MuqaConnectButton className='mx-4 bg-blue px-10 py-3' />
)}
<Link
href={CodaFormProjectLink}
target='_blank'
Expand Down
23 changes: 23 additions & 0 deletions apps/platform/src/app/api/auth/[...nextauth]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import NextAuth from 'next-auth/next';
import authOptions from '@/lib/next-auth/web3-provider/auth-options';
import { NextRequest } from 'next/server';

// Below duplicated interface from next-auth/next because it's not exported
interface RouteHandlerContext {
params: { nextauth: string[] }
}

const handler = async function auth(req: NextRequest, context: RouteHandlerContext) {
const isDefaultSigninPage =
req.method === "GET" && req?.nextUrl.search.includes('signin');

// Below to skip showing the default signin page
// because it is handled via web3 provider
if (isDefaultSigninPage) {
authOptions.providers.pop();
}

return NextAuth(req, context, authOptions);
}

export { handler as GET, handler as POST };
44 changes: 44 additions & 0 deletions apps/platform/src/app/api/auth/web3/nonce/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import crypto from 'crypto';
import { z } from 'zod';
import { isAddress } from 'ethers';
import { validateBody } from '@/lib/util/auth';
import { upsertUserNonce, getUserWithNonce, createUserWithNonce } from '@muqa/db';
import { NextRequest, NextResponse } from 'next/server';

const WalletNonceRequestSchema = z.object({
address: z.string().refine(isAddress, { message: 'Invalid address' })
});

export type WalletNonceRequestDTO = z.infer<typeof WalletNonceRequestSchema>;
export type WalletNonceResponse = {
nonce: string;
}

const NONCE_EXPIRATION = 60 * 60 * 1000; // 1 hour

function generateNonceData() {
const nonce = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + NONCE_EXPIRATION);
return { nonce, expiresAt };
}

export async function POST(req: NextRequest) {
const body = await req.json();
const result = validateBody(body, WalletNonceRequestSchema);
if (result.error) {
return NextResponse.json({ error: result.error.message }, { status: 400 });
}

const { address } = result.data as WalletNonceRequestDTO;

const user = await getUserWithNonce(address);
const nonceData = generateNonceData();

if (user) {
await upsertUserNonce(user, nonceData);
} else {
await createUserWithNonce(address, nonceData);
}

return NextResponse.json({ nonce: nonceData.nonce });
}
60 changes: 46 additions & 14 deletions apps/platform/src/app/components/MuqaConnectButton.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
'use client';

import { useAccount, useConnect, useDisconnect } from 'wagmi';
import { useAccount, useConnect, useDisconnect, useSignMessage } from 'wagmi';

import { Button, ButtonProps } from './Button';
import { comethConnector } from '@allo/kit/wagmi';
import { PropsWithChildren, ReactNode, useState } from 'react';
import { comethConnector } from '@allo/kit';
import { PropsWithChildren, useState } from 'react';
import { useTranslations } from 'next-intl';
import { signIn, signOut } from 'next-auth/react';
import { WalletNonceResponse } from '../api/auth/web3/nonce/route';

const TRUNCATE_LENGTH = 20;
const TRUNCATE_OFFSET = 3;
Expand Down Expand Up @@ -51,21 +53,51 @@ function LoadingIcon() {
);
}

export function MuqaConnectButton({ children, ...props }: PropsWithChildren<ButtonProps>): JSX.Element {
const account = useAccount()
const { connect } = useConnect()
const { disconnect } = useDisconnect()
async function getNonce(address: `0x${string}`) {
const body = JSON.stringify({ address });
const headers = {
'Content-Type': 'application/json',
};

const label = getLabel();
const res = await fetch('/api/auth/web3/nonce', {
method: 'POST',
headers,
body,
});

const onClick = account.status === 'disconnected'
? () => connect({ connector: comethConnector })
: () => disconnect();
const { nonce } = await res.json() as WalletNonceResponse;
return nonce;
}

const [showTooltip, setShowTooltip] = useState(false);
export function MuqaConnectButton({ children, ...props }: PropsWithChildren<ButtonProps>): JSX.Element {
const account = useAccount();
const { connectAsync } = useConnect();
const { disconnect } = useDisconnect();
const { signMessageAsync } = useSignMessage();
const [showTooltip, setShowTooltip] = useState(false);
const label = getLabel();

const onMouseEnter = () => setShowTooltip(!!account?.address && true);
const onMouseLeave = () => setShowTooltip(false);
const onMouseEnter = () => setShowTooltip(!!account?.address && true);
const onMouseLeave = () => setShowTooltip(false);

async function signInWithWeb3() {
const { accounts } = await connectAsync({ connector: comethConnector });
const [address] = accounts;
const nonce = await getNonce(address);
const signedNonce = await signMessageAsync({ message: nonce });
await signIn('credentials', { address, signedNonce, redirect: false });
}

async function signOutWithWeb3() {
disconnect();
await signOut();
}

function onClick() {
return account.isConnected
? signOutWithWeb3()
: signInWithWeb3();
}

return (
<div className='relative'>
Expand Down
20 changes: 12 additions & 8 deletions apps/platform/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { NextIntlClientProvider } from 'next-intl';
import { getLocale, getMessages } from 'next-intl/server';

import { WalletStatus } from './WalletStatus';
import { AlloKitProviders } from './providers';
import { AlloKitProviders, MuqaSessionProvider } from './providers';
import Header from '@/app/Header';
import NotificationBar from '@/app/components/NotificationBar';
import Footer from '@/app/components/footer/Footer';
Expand All @@ -19,8 +19,10 @@ export const metadata: Metadata = {

export default async function RootLayout({
children,
session,
}: Readonly<{
children: React.ReactNode;
session: any;
}>) {
const locale = await getLocale();

Expand All @@ -31,13 +33,15 @@ export default async function RootLayout({
<html lang={locale}>
<body className={`${dmSans.className} bg-[#FBFBFB]`}>
<NextIntlClientProvider messages={messages}>
<AlloKitProviders>
<NotificationBar message='notification' />
<Header />
<WalletStatus />
<main>{children}</main>
<Footer />
</AlloKitProviders>
<MuqaSessionProvider session={session}>
<AlloKitProviders>
<NotificationBar message='notification' />
<Header />
<WalletStatus />
<main>{children}</main>
<Footer />
</AlloKitProviders>
</MuqaSessionProvider>
</NextIntlClientProvider>
</body>
</html>
Expand Down
11 changes: 11 additions & 0 deletions apps/platform/src/app/providers.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
'use client';

import { ApiProvider, ComethProvider, strategies } from '@allo/kit';
import { SessionProvider } from "next-auth/react";

export function MuqaSessionProvider({
children,
session,
}: Readonly<{
children: React.ReactNode;
session: any;
}>) {
return <SessionProvider session={session}>{children}</SessionProvider>;
}

export function AlloKitProviders({
children,
Expand Down
26 changes: 26 additions & 0 deletions apps/platform/src/lib/cometh/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import axios from 'axios';

const BASE_URL = 'https://api.connect.cometh.io';
const API_KEY = process.env.COMETH_API_KEY;

type verifySignatureResponse = {
success: boolean,
result: boolean
}

const api = axios.create({
baseURL: BASE_URL,
headers: { common: {
apikey: API_KEY
}}
});

export async function verifySignature(
address: string,
message: string,
signature: string
): Promise<verifySignatureResponse> {
const body = { message, signature };
const response = await api.post(`/wallets/${address}/is-valid-signature`, body);
return response.data;
}
24 changes: 24 additions & 0 deletions apps/platform/src/lib/next-auth/web3-provider/auth-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { prisma } from '@muqa/db';
import { AuthOptions } from 'next-auth';;
import { PrismaAdapter } from '@next-auth/prisma-adapter';
import Web3CredentialsProvider from './provider';

const authOptions: AuthOptions = {
providers: [
Web3CredentialsProvider
],
adapter: PrismaAdapter(prisma),
session: {
strategy: 'jwt',
},
secret: process.env.NEXTAUTH_SECRET,
callbacks: {
async session({ session, token }: { session: any; token: any }) {
session.address = token.sub
session.user.name = token.sub
return session
},
},
};

export default authOptions;
34 changes: 34 additions & 0 deletions apps/platform/src/lib/next-auth/web3-provider/authorize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { deleteUserNonce, getUserWithNonce } from '@muqa/db';
import { RequestInternal } from 'next-auth';;
import { verifySignature } from '@/lib/cometh/api';

export default async function authorize(
credentials: Record<'address' | 'signedNonce', string> | undefined,
req: Pick<RequestInternal, 'body' | 'headers' | 'method' | 'query'>
) {
if (!credentials) return null;

const { address, signedNonce } = credentials;

// Get user from database with their generated nonce
const user = await getUserWithNonce(address);

if (!user?.authNonce) return null;

// Check nonce signature against Cometh's api
const verification = await verifySignature(
address, user.authNonce.nonce, signedNonce);

if (!verification.result) return null;

// Check that the nonce is not expired
if (user.authNonce.expiresAt < new Date()) return null;

// Everything is fine, clear the nonce and return the user
await deleteUserNonce(user);

return {
id: user.id,
address: user.address,
};
}
13 changes: 13 additions & 0 deletions apps/platform/src/lib/next-auth/web3-provider/provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import CredentialsProvider from 'next-auth/providers/credentials';
import authorize from './authorize';

const Web3CredentialsProvider = CredentialsProvider({
name: 'Web3 Credentials Auth',
credentials: {
address: { label: 'Public Address', type: 'text' },
signedNonce: { label: 'Signed Nonce', type: 'text' },
},
authorize,
});

export default Web3CredentialsProvider;
5 changes: 5 additions & 0 deletions apps/platform/src/lib/util/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { ZodSchema } from 'zod';

export function validateBody(body: any, schema: ZodSchema) {
return schema.safeParse(body);
}
4 changes: 3 additions & 1 deletion apps/platform/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
}
],
"paths": {
"@/*": ["./src/*"]
"@/*": ["./src/*"],
"@/lib": ["./src/lib/*"],
"@/util": ["./src/lib/util/*"],
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
Expand Down
Loading

0 comments on commit 0166859

Please sign in to comment.