Implement authentication with SIWE #4

Merged
nabarun merged 6 commits from nv-siwe into main 2024-10-18 12:47:12 +00:00
8 changed files with 1332 additions and 252 deletions
Showing only changes of commit 4a81ee86f6 - Show all commits

View File

@ -40,7 +40,7 @@ router.post('/register', async (req, res) => {
userEmail: email, userEmail: email,
userName: email.split('@')[0], userName: email.split('@')[0],
}); });
req.session.userId = user.id; req.session.address = user.id;
res.sendStatus(200); res.sendStatus(200);
}); });
@ -52,7 +52,7 @@ router.post('/authenticate', async (req, res) => {
signedWhoamiRequest, signedWhoamiRequest,
); );
if (user) { if (user) {
req.session.userId = user.id; req.session.address = user.id;
res.sendStatus(200); res.sendStatus(200);
} else { } else {
res.sendStatus(401); res.sendStatus(401);
@ -64,21 +64,19 @@ router.post('/authenticate', async (req, res) => {
// //
router.post('/validate', async (req, res) => { router.post('/validate', async (req, res) => {
const { message, signature, action } = req.body; const { message, signature } = req.body;
const { success, data } = await new SiweMessage(message).verify({ const { success, data } = await new SiweMessage(message).verify({
signature, signature,
}); });
console.log("VALIDATE CALL",message, signature )
if (!success) { if (!success) {
return res.send({ success }); return res.send({ success, error: 'SIWE verifcation failed' } );
} }
const service: Service = req.app.get('service'); const service: Service = req.app.get('service');
const user = await service.getUserByEthAddress(data.address); const user = await service.getUserByEthAddress(data.address);
if (action === 'signup') { if (!user) {
if (user) {
return res.send({ success: false, error: 'user_already_exists' });
}
const newUser = await service.createUser({ const newUser = await service.createUser({
ethAddress: data.address, ethAddress: data.address,
email: '', email: '',
@ -86,13 +84,13 @@ router.post('/validate', async (req, res) => {
subOrgId: '', subOrgId: '',
turnkeyWalletId: '', turnkeyWalletId: '',
}); });
req.session.userId = newUser.id; req.session.address = newUser.id;
} else if (action === 'login') { req.session.chainId = data.chainId;
if (!user) { } else {
return res.send({ success: false, error: 'user_not_found' }); req.session.address = user.id;
} req.session.chainId = data.chainId;
req.session.userId = user.id;
} }
console.log("VALIDATE CALL FINISHED", req.session)
res.send({ success }); res.send({ success });
}); });
@ -101,9 +99,10 @@ router.post('/validate', async (req, res) => {
// General // General
// //
router.get('/session', (req, res) => { router.get('/session', (req, res) => {
if (req.session.userId) { if (req.session.address && req.session.chainId) {
res.send({ res.send({
userId: req.session.userId, address: req.session.address,
chainId: req.session.chainId
}); });
} else { } else {
res.status(401).send({ error: 'Unauthorized: No active session' }); res.status(401).send({ error: 'Unauthorized: No active session' });

View File

@ -24,7 +24,8 @@ const log = debug('snowball:server');
declare module 'express-session' { declare module 'express-session' {
interface SessionData { interface SessionData {
userId: string; address: string;
chainId: number;
} }
} }
@ -54,13 +55,13 @@ export const createAndStartServer = async (
context: async ({ req }) => { context: async ({ req }) => {
// https://www.apollographql.com/docs/apollo-server/v3/security/authentication#api-wide-authorization // https://www.apollographql.com/docs/apollo-server/v3/security/authentication#api-wide-authorization
const { userId } = req.session; const { address } = req.session;
if (!userId) { if (!address) {
throw new AuthenticationError('Unauthorized: No active session'); throw new AuthenticationError('Unauthorized: No active session');
} }
const user = await service.getUser(userId); const user = await service.getUser(address);
return { user }; return { user };
}, },

View File

@ -38,7 +38,7 @@
"@snowballtools/smartwallet-alchemy-light": "^0.2.0", "@snowballtools/smartwallet-alchemy-light": "^0.2.0",
"@snowballtools/types": "^0.2.0", "@snowballtools/types": "^0.2.0",
"@snowballtools/utils": "^0.1.1", "@snowballtools/utils": "^0.1.1",
"@tanstack/react-query": "^5.22.2", "@tanstack/react-query": "5.22.2",
"@testing-library/jest-dom": "^5.17.0", "@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0", "@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
@ -46,8 +46,8 @@
"@turnkey/sdk-react": "^0.1.0", "@turnkey/sdk-react": "^0.1.0",
"@turnkey/webauthn-stamper": "^0.5.0", "@turnkey/webauthn-stamper": "^0.5.0",
"@walletconnect/ethereum-provider": "^2.12.2", "@walletconnect/ethereum-provider": "^2.12.2",
"@web3modal/siwe": "^4.0.5", "@web3modal/siwe": "4.0.5",
"@web3modal/wagmi": "^4.0.5", "@web3modal/wagmi": "4.0.5",
"assert": "^2.1.0", "assert": "^2.1.0",
"axios": "^1.6.7", "axios": "^1.6.7",
"clsx": "^2.1.0", "clsx": "^2.1.0",
@ -68,11 +68,12 @@
"react-oauth-popup": "^1.0.5", "react-oauth-popup": "^1.0.5",
"react-router-dom": "^6.20.1", "react-router-dom": "^6.20.1",
"react-timer-hook": "^3.0.7", "react-timer-hook": "^3.0.7",
"siwe": "^2.1.4", "siwe": "2.1.4",
"tailwind-variants": "^0.2.0", "tailwind-variants": "^0.2.0",
"usehooks-ts": "^2.15.1", "usehooks-ts": "^2.15.1",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"viem": "^2.7.11", "viem": "^2.7.11",
"wagmi": "2.5.7",
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4"
}, },
"devDependencies": { "devDependencies": {

View File

@ -59,19 +59,19 @@ const router = createBrowserRouter([
function App() { function App() {
// Hacky way of checking session // Hacky way of checking session
// TODO: Handle redirect backs // TODO: Handle redirect backs
useEffect(() => { // useEffect(() => {
fetch(`${baseUrl}/auth/session`, { // fetch(`${baseUrl}/auth/session`, {
credentials: 'include', // credentials: 'include',
}).then((res) => { // }).then((res) => {
if (res.status !== 200) { // if (res.status !== 200) {
localStorage.clear(); // localStorage.clear();
const path = window.location.pathname; // const path = window.location.pathname;
if (path !== '/login' && path !== '/signup') { // if (path !== '/login' && path !== '/signup') {
window.location.pathname = '/login'; // window.location.pathname = '/login';
} // }
} // }
}); // });
}, []); // }, []);
return ( return (
<Web3Provider> <Web3Provider>

View File

@ -1,16 +1,112 @@
import { ReactNode } from 'react'; import React, { ReactNode } from 'react';
import { SiweMessage, generateNonce } from 'siwe';
import { WagmiProvider } from 'wagmi';
import { arbitrum, mainnet } from 'wagmi/chains';
import axios from 'axios';
import { createWeb3Modal } from '@web3modal/wagmi/react';
import { defaultWagmiConfig } from '@web3modal/wagmi/react/config';
import { createSIWEConfig } from '@web3modal/siwe';
import type {
SIWECreateMessageArgs,
SIWEVerifyMessageArgs,
} from '@web3modal/core';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { VITE_WALLET_CONNECT_ID } from 'utils/constants'; // if (!process.env.VITE_WALLET_CONNECT_ID) {
// throw new Error('Error: VITE_WALLET_CONNECT_ID env config is not set');
// }
const WALLET_CONNECT_ID="d37f5a2f09d22f5e3ccaff4bbc93d37c"
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const axiosInstance = axios.create({
baseURL: 'http://127.0.0.1:8000',
// headers: {
// 'Content-Type': 'application/json',
// 'Access-Control-Allow-Origin': '*',
// },
// withCredentials: true,
});
const metadata = {
name: 'Web3Modal',
description: 'Snowball Web3Modal',
url: window.location.origin,
icons: ['https://avatars.githubusercontent.com/u/37784886'],
};
const chains = [mainnet, arbitrum] as const;
const config = defaultWagmiConfig({
chains,
projectId: WALLET_CONNECT_ID,
metadata,
});
const siweConfig = createSIWEConfig({
createMessage: ({ nonce, address, chainId }: SIWECreateMessageArgs) =>
new SiweMessage({
version: '1',
domain: window.location.host,
uri: window.location.origin,
address,
chainId,
nonce,
// Human-readable ASCII assertion that the user will sign, and it must not contain `\n`.
statement: 'Sign in With Ethereum.',
}).prepareMessage(),
getNonce: async () => {
return generateNonce()
},
getSession: async () => {
try {
const session = (await axiosInstance.get('/auth/session')).data;
const { address, chainId } = session;
return { address, chainId };
} catch (err) {
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
throw new Error('Failed to get session!');
}
},
verifyMessage: async ({ message, signature }: SIWEVerifyMessageArgs) => {
try {
const { success } = (
await axiosInstance.post('/auth/validate', {
message,
signature,
})
).data;
return success;
} catch (error) {
return false;
}
},
signOut: async () => {
// try {
// const { success } = (await axiosInstance.post('/auth/logout')).data;
// return success;
// } catch (error) {
// return false;
// }
return false
},
onSignOut: () => {
window.location.href = '/login';
},
onSignIn: () => {
window.location.href = '/';
},
});
if (!VITE_WALLET_CONNECT_ID) { createWeb3Modal({
throw new Error('Error: REACT_APP_WALLET_CONNECT_ID env config is not set'); siweConfig,
} wagmiConfig: config,
projectId: WALLET_CONNECT_ID,
export default function Web3Provider({ children }: { children: ReactNode }) { });
export default function Web3ModalProvider({
children,
}: {
children: ReactNode;
}) {
return ( return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider> <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</WagmiProvider>
); );
} }

View File

@ -15,6 +15,8 @@ import { SERVER_GQL_PATH } from './constants';
import { Toaster } from 'components/shared/Toast'; import { Toaster } from 'components/shared/Toast';
import { LogErrorBoundary } from 'utils/log-error'; import { LogErrorBoundary } from 'utils/log-error';
import { baseUrl } from 'utils/constants'; import { baseUrl } from 'utils/constants';
import Web3ModalProvider from './context/Web3Provider';
console.log(`v-0.0.9`); console.log(`v-0.0.9`);
@ -31,10 +33,12 @@ root.render(
<LogErrorBoundary> <LogErrorBoundary>
<React.StrictMode> <React.StrictMode>
<ThemeProvider> <ThemeProvider>
<Web3ModalProvider>
<GQLClientProvider client={gqlClient}> <GQLClientProvider client={gqlClient}>
<App /> <App />
<Toaster /> <Toaster />
</GQLClientProvider> </GQLClientProvider>
</Web3ModalProvider>
</ThemeProvider> </ThemeProvider>
</React.StrictMode> </React.StrictMode>
</LogErrorBoundary>, </LogErrorBoundary>,

View File

@ -20,51 +20,52 @@ import { signInWithEthereum } from 'utils/siwe';
import { useSnowball } from 'utils/use-snowball'; import { useSnowball } from 'utils/use-snowball';
import { logError } from 'utils/log-error'; import { logError } from 'utils/log-error';
type Provider = 'google' | 'github' | 'apple' | 'email' | 'passkey'; // type Provider = 'google' | 'github' | 'apple' | 'email' | 'passkey';
type Props = { type Props = {
onDone: () => void; onDone: () => void;
}; };
export const Login = ({ onDone }: Props) => { export const Login = ({ onDone }: Props) => {
const snowball = useSnowball(); // const snowball = useSnowball();
const [error, setError] = useState<string>(''); // const [error, setError] = useState<string>('');
const [provider, setProvider] = useState<Provider | false>(false); // const [provider, setProvider] = useState<Provider | false>(false);
// const loading = snowball.auth.state.loading && provider; // const loading = snowball.auth.state.loading && provider;
const loading = provider; // const loading = provider;
const { toast } = useToast(); // const { toast } = useToast();
if (provider === 'email') { console.log(">>ondone", onDone)
return <CreatePasskey onDone={onDone} />; // if (provider === 'email') {
} // return <CreatePasskey onDone={onDone} />;
// }
async function handleSigninRedirect() { // async function handleSigninRedirect() {
let wallet: PKPEthersWallet | undefined; // let wallet: PKPEthersWallet | undefined;
const { google } = snowball.auth; // const { google } = snowball.auth;
if (google.canHandleOAuthRedirectBack()) { // if (google.canHandleOAuthRedirectBack()) {
setProvider('google'); // setProvider('google');
console.log('Handling google redirect back'); // console.log('Handling google redirect back');
try { // try {
await google.handleOAuthRedirectBack(); // await google.handleOAuthRedirectBack();
// @ts-ignore // // @ts-ignore
wallet = await google.getEthersWallet(); // wallet = await google.getEthersWallet();
// @ts-ignore // // @ts-ignore
const result = await signInWithEthereum(1, 'login', wallet); // const result = await signInWithEthereum(1, 'login', wallet);
if (result.error) { // if (result.error) {
setError(result.error); // setError(result.error);
setProvider(false); // setProvider(false);
wallet = undefined; // wallet = undefined;
logError(new Error(result.error)); // logError(new Error(result.error));
return; // return;
} // }
} catch (err: any) { // } catch (err: any) {
setError(err.message); // setError(err.message);
logError(err); // logError(err);
setProvider(false); // setProvider(false);
return; // return;
} // }
} // }
// if (apple.canHandleOAuthRedirectBack()) { // if (apple.canHandleOAuthRedirectBack()) {
// setProvider('apple'); // setProvider('apple');
// console.log('Handling apple redirect back'); // console.log('Handling apple redirect back');
@ -86,14 +87,14 @@ export const Login = ({ onDone }: Props) => {
// } // }
// } // }
if (wallet) { // if (wallet) {
window.location.pathname = '/'; // window.location.pathname = '/';
} // }
} // }
useEffect(() => { // useEffect(() => {
handleSigninRedirect(); // handleSigninRedirect();
}, []); // }, []);
return ( return (
<div> <div>
@ -105,8 +106,8 @@ export const Login = ({ onDone }: Props) => {
<WavyBorder className="self-stretch" variant="stroke" /> <WavyBorder className="self-stretch" variant="stroke" />
<div className="self-stretch p-4 xs:p-6 flex-col justify-center items-center gap-8 flex"> <div className="self-stretch p-4 xs:p-6 flex-col justify-center items-center gap-8 flex">
<div className="self-stretch p-5 bg-slate-50 rounded-xl shadow flex-col justify-center items-center gap-6 flex"> {/* <div className="self-stretch p-5 bg-slate-50 rounded-xl shadow flex-col justify-center items-center gap-6 flex"> */}
<div className="self-stretch flex-col justify-center items-center gap-4 flex"> {/* <div className="self-stretch flex-col justify-center items-center gap-4 flex">
<KeyIcon /> <KeyIcon />
<div className="self-stretch flex-col justify-center items-center gap-2 flex"> <div className="self-stretch flex-col justify-center items-center gap-2 flex">
<div className="self-stretch text-center text-sky-950 text-lg font-medium font-display leading-normal"> <div className="self-stretch text-center text-sky-950 text-lg font-medium font-display leading-normal">
@ -116,9 +117,9 @@ export const Login = ({ onDone }: Props) => {
Use it to sign in securely without using a password. Use it to sign in securely without using a password.
</div> </div>
</div> </div>
</div> </div> */}
<div className="self-stretch justify-center items-stretch xxs:items-center gap-3 flex flex-col xxs:flex-row"> {/* <div className="self-stretch justify-center items-stretch xxs:items-center gap-3 flex flex-col xxs:flex-row"> */}
<Button {/* <Button
as="a" as="a"
leftIcon={<QuestionMarkRoundFilledIcon />} leftIcon={<QuestionMarkRoundFilledIcon />}
variant={'tertiary'} variant={'tertiary'}
@ -126,8 +127,8 @@ export const Login = ({ onDone }: Props) => {
href="https://safety.google/authentication/passkey/" href="https://safety.google/authentication/passkey/"
> >
Learn more Learn more
</Button> </Button> */}
<Button {/* <Button
rightIcon={ rightIcon={
loading && loading === 'passkey' ? ( loading && loading === 'passkey' ? (
<LoaderIcon className="animate-spin" /> <LoaderIcon className="animate-spin" />
@ -142,10 +143,10 @@ export const Login = ({ onDone }: Props) => {
}} }}
> >
Sign In with Passkey Sign In with Passkey
</Button> </Button> */}
</div> {/* </div> */}
<div className="h-5 justify-center items-center gap-2 inline-flex"> {/* <div className="h-5 justify-center items-center gap-2 inline-flex">
<div className="text-center text-slate-600 text-sm font-normal font-['Inter'] leading-tight"> <div className="text-center text-slate-600 text-sm font-normal font-['Inter'] leading-tight">
Lost your passkey? Lost your passkey?
</div> </div>
@ -155,19 +156,20 @@ export const Login = ({ onDone }: Props) => {
</button> </button>
<LinkIcon /> <LinkIcon />
</div> </div>
</div> </div> */}
</div> {/* </div> */}
<div className="self-stretch justify-start items-center gap-8 inline-flex"> {/* <div className="self-stretch justify-start items-center gap-8 inline-flex">
<DotBorder className="flex-1" /> <DotBorder className="flex-1" />
<div className="text-center text-slate-400 text-xs font-normal font-['JetBrains Mono'] leading-none"> <div className="text-center text-slate-400 text-xs font-normal font-['JetBrains Mono'] leading-none">
OR OR
</div> </div>
<DotBorder className="flex-1" /> <DotBorder className="flex-1" />
</div> </div> */}
<div className="self-stretch flex-col justify-center items-center gap-3 flex"> <div className="self-stretch flex-col justify-center items-center gap-3 flex">
<Button <w3m-button />
{/* <Button
leftIcon={<GoogleIcon />} leftIcon={<GoogleIcon />}
rightIcon={ rightIcon={
loading && loading === 'google' ? ( loading && loading === 'google' ? (
@ -176,16 +178,16 @@ export const Login = ({ onDone }: Props) => {
} }
onClick={() => { onClick={() => {
setProvider('google'); setProvider('google');
snowball.auth.google.startOAuthRedirect(); // snowball.auth.google.startOAuthRedirect();
}} }}
className="flex-1 self-stretch" className="flex-1 self-stretch"
variant={'tertiary'} variant={'tertiary'}
disabled={!!loading} disabled={!!loading}
> >
Continue with Google Continue with Google
</Button> </Button> */}
<Button {/* <Button
leftIcon={<GithubIcon />} leftIcon={<GithubIcon />}
rightIcon={ rightIcon={
loading && loading === 'github' ? ( loading && loading === 'github' ? (
@ -194,23 +196,23 @@ export const Login = ({ onDone }: Props) => {
} }
onClick={async () => { onClick={async () => {
setProvider('github'); setProvider('github');
await new Promise((resolve) => setTimeout(resolve, 800)); // await new Promise((resolve) => setTimeout(resolve, 800));
setProvider(false); // setProvider(false);
toast({ // toast({
id: 'coming-soon', // id: 'coming-soon',
title: 'Sign-in with GitHub is coming soon!', // title: 'Sign-in with GitHub is coming soon!',
variant: 'info', // variant: 'info',
onDismiss() {}, // onDismiss() {},
}); // });
}} }}
className="flex-1 self-stretch" className="flex-1 self-stretch"
variant={'tertiary'} variant={'tertiary'}
disabled={!!loading} disabled={!!loading}
> >
Continue with GitHub Continue with GitHub
</Button> </Button> */}
<Button {/* <Button
leftIcon={<AppleIcon />} leftIcon={<AppleIcon />}
rightIcon={ rightIcon={
loading && loading === 'apple' ? ( loading && loading === 'apple' ? (
@ -220,14 +222,14 @@ export const Login = ({ onDone }: Props) => {
onClick={async () => { onClick={async () => {
setProvider('apple'); setProvider('apple');
// snowball.auth.apple.startOAuthRedirect(); // snowball.auth.apple.startOAuthRedirect();
await new Promise((resolve) => setTimeout(resolve, 800)); // await new Promise((resolve) => setTimeout(resolve, 800));
setProvider(false); // setProvider(false);
toast({ // toast({
id: 'coming-soon', // id: 'coming-soon',
title: 'Sign-in with Apple is coming soon!', // title: 'Sign-in with Apple is coming soon!',
variant: 'info', // variant: 'info',
onDismiss() {}, // onDismiss() {},
}); // });
}} }}
className={`flex-1 self-stretch border-black enabled:bg-black text-white ${ className={`flex-1 self-stretch border-black enabled:bg-black text-white ${
loading && loading === 'apple' ? 'disabled:bg-black' : '' loading && loading === 'apple' ? 'disabled:bg-black' : ''
@ -236,17 +238,17 @@ export const Login = ({ onDone }: Props) => {
disabled={!!loading} disabled={!!loading}
> >
Continue with Apple Continue with Apple
</Button> </Button> */}
</div> </div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{error && ( {/* {error && (
<div className="justify-center items-center gap-2 inline-flex"> <div className="justify-center items-center gap-2 inline-flex">
<div className="text-red-500 text-sm">Error: {error}</div> <div className="text-red-500 text-sm">Error: {error}</div>
</div> </div>
)} )} */}
<div className="h-5 justify-center items-center gap-2 inline-flex"> {/* <div className="h-5 justify-center items-center gap-2 inline-flex">
<div className="text-center text-slate-600 text-sm font-normal font-['Inter'] leading-tight"> <div className="text-center text-slate-600 text-sm font-normal font-['Inter'] leading-tight">
Don't have an account? Don't have an account?
</div> </div>
@ -258,7 +260,7 @@ export const Login = ({ onDone }: Props) => {
Sign up now Sign up now
</Link> </Link>
</div> </div>
</div> </div> */}
</div> </div>
</div> </div>
</div> </div>

1221
yarn.lock

File diff suppressed because it is too large Load Diff