Implement authentication with SIWE #4

Merged
nabarun merged 6 commits from nv-siwe into main 2024-10-18 12:47:12 +00:00
7 changed files with 14 additions and 481 deletions
Showing only changes of commit 734917f87b - Show all commits

View File

@ -5,19 +5,6 @@ import { authenticateUser, createUser } from '../turnkey-backend';
const router = Router(); const router = Router();
//
// Access Code
//
router.post('/accesscode', async (req, res) => {
console.log('Access Code', req.body);
const { accesscode } = req.body;
if (accesscode === '44444') {
return res.send({ isValid: true });
} else {
return res.sendStatus(204);
}
});
// //
// Turnkey // Turnkey
// //
@ -59,10 +46,6 @@ router.post('/authenticate', async (req, res) => {
} }
}); });
//
// Lit
//
router.post('/validate', async (req, res) => { router.post('/validate', async (req, res) => {
const { message, signature } = req.body; const { message, signature } = req.body;
const { success, data } = await new SiweMessage(message).verify({ const { success, data } = await new SiweMessage(message).verify({

View File

@ -1,4 +1,5 @@
import React, { ReactNode, Suspense } from 'react'; import { ReactNode } from 'react';
import assert from 'assert';
import { SiweMessage, generateNonce } from 'siwe'; import { SiweMessage, generateNonce } from 'siwe';
import { WagmiProvider } from 'wagmi'; import { WagmiProvider } from 'wagmi';
import { arbitrum, mainnet } from 'wagmi/chains'; import { arbitrum, mainnet } from 'wagmi/chains';
@ -12,12 +13,16 @@ import type {
} from '@web3modal/core'; } from '@web3modal/core';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// TODO: Use environment variable import { VITE_WALLET_CONNECT_ID, baseUrl } from 'utils/constants';
const WALLET_CONNECT_ID="d37f5a2f09d22f5e3ccaff4bbc93d37c"
if (!VITE_WALLET_CONNECT_ID) {
throw new Error('Error: REACT_APP_WALLET_CONNECT_ID env config is not set');
}
assert(baseUrl, 'VITE_SERVER_URL is not set in env');
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const axiosInstance = axios.create({ const axiosInstance = axios.create({
// TODO: Use environment variable baseURL: baseUrl,
baseURL: 'http://localhost:8000',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
@ -33,7 +38,7 @@ const metadata = {
const chains = [mainnet, arbitrum] as const; const chains = [mainnet, arbitrum] as const;
const config = defaultWagmiConfig({ const config = defaultWagmiConfig({
chains, chains,
projectId: WALLET_CONNECT_ID, projectId: VITE_WALLET_CONNECT_ID,
metadata, metadata,
}); });
const siweConfig = createSIWEConfig({ const siweConfig = createSIWEConfig({
@ -95,7 +100,7 @@ const siweConfig = createSIWEConfig({
createWeb3Modal({ createWeb3Modal({
siweConfig, siweConfig,
wagmiConfig: config, wagmiConfig: config,
projectId: WALLET_CONNECT_ID, projectId: VITE_WALLET_CONNECT_ID,
}); });
export default function Web3ModalProvider({ export default function Web3ModalProvider({
children, children,

View File

@ -1,5 +1,5 @@
import { CloudyFlow } from 'components/CloudyFlow'; import { CloudyFlow } from 'components/CloudyFlow';
import { SnowballAuth } from './auth/SnowballAuth'; import { Login } from './auth/Login';
const AuthPage = () => { const AuthPage = () => {
return ( return (
@ -18,7 +18,7 @@ const AuthPage = () => {
</div> </div>
<div className="pb-12 relative z-10 flex-1 flex-center"> <div className="pb-12 relative z-10 flex-1 flex-center">
<div className="max-w-[520px] w-full bg-white rounded-xl shadow"> <div className="max-w-[520px] w-full bg-white rounded-xl shadow">
<SnowballAuth /> <Login />
</div> </div>
</div> </div>
</CloudyFlow> </CloudyFlow>

View File

@ -1,99 +0,0 @@
import React, { useState } from 'react';
import { Button } from 'components/shared/Button';
import {
ArrowRightCircleFilledIcon,
LoaderIcon,
} from 'components/shared/CustomIcon';
import { WavyBorder } from 'components/shared/WavyBorder';
import { VerifyCodeInput } from 'components/shared/VerifyCodeInput';
import { verifyAccessCode } from 'utils/accessCode';
type AccessMethod = 'accesscode' | 'passkey';
type Err = { type: AccessMethod; message: string };
type AccessCodeProps = {
onCorrectAccessCode: () => void;
};
export const AccessCode: React.FC<AccessCodeProps> = ({
onCorrectAccessCode,
}) => {
const [accessCode, setAccessCode] = useState(' ');
const [error, setError] = useState<Err | null>();
const [accessMethod, setAccessMethod] = useState<AccessMethod | false>(false);
async function validateAccessCode() {
setAccessMethod('accesscode');
try {
const isValidAccessCode = await verifyAccessCode(accessCode);
// add a pause for ux
await new Promise((resolve) => setTimeout(resolve, 250));
if (isValidAccessCode) {
localStorage.setItem('accessCode', accessCode);
onCorrectAccessCode();
} else {
setError({
type: 'accesscode',
message: 'Invalid access code',
});
}
} catch (err: any) {
setError({ type: 'accesscode', message: err.message });
}
}
const loading = accessMethod;
const isValidAccessCodeLength = accessCode.trim().length === 5;
return (
<div>
<div className="self-stretch p-3 xs:p-6 flex-col justify-center items-center gap-5 flex">
<div className="self-stretch text-center text-sky-950 text-2xl font-medium font-display leading-tight">
Access Code
</div>
</div>
<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 flex-col gap-8 flex">
<div className="flex-col justify-start items-start gap-2 inline-flex">
<VerifyCodeInput
loading={!!loading}
code={accessCode}
setCode={setAccessCode}
submitCode={validateAccessCode}
/>
</div>
<Button
rightIcon={
loading && loading === 'accesscode' ? (
<LoaderIcon className="animate-spin" />
) : (
<ArrowRightCircleFilledIcon height="16" />
)
}
onClick={validateAccessCode}
variant={'secondary'}
disabled={!accessCode || !isValidAccessCodeLength || !!loading}
>
Submit
</Button>
{error && error.type === 'accesscode' && (
<div className="flex flex-col gap-3">
<div className="justify-center items-center gap-2 inline-flex">
<div className="text-red-500 text-sm">
Error: {error.message}.{' '}
<a href="/signup" className="underline">
Try again?
</a>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
};

View File

@ -1,20 +0,0 @@
import React, { useState } from 'react';
import { AccessCode } from './AccessCode';
import { SignUp } from './SignUp';
type AccessSignUpProps = {
onDone: () => void;
};
export const AccessSignUp: React.FC<AccessSignUpProps> = ({ onDone }) => {
const [isValidAccessCode, setIsValidAccessCode] = useState<boolean>(
!!localStorage.getItem('accessCode'),
);
return isValidAccessCode ? (
<SignUp onDone={onDone} />
) : (
<AccessCode onCorrectAccessCode={() => setIsValidAccessCode(true)} />
);
};

View File

@ -1,287 +0,0 @@
import { Button } from 'components/shared/Button';
import {
ArrowRightCircleFilledIcon,
GithubIcon,
LoaderIcon,
} from 'components/shared/CustomIcon';
import { GoogleIcon } from 'components/shared/CustomIcon/GoogleIcon';
import { DotBorder } from 'components/shared/DotBorder';
import { WavyBorder } from 'components/shared/WavyBorder';
import { useEffect, useState } from 'react';
import { useSnowball } from 'utils/use-snowball';
import { Input } from 'components/shared/Input';
import { AppleIcon } from 'components/shared/CustomIcon/AppleIcon';
import { Link } from 'react-router-dom';
import { useToast } from 'components/shared/Toast';
import { PKPEthersWallet } from '@lit-protocol/pkp-ethers';
import { signInWithEthereum } from 'utils/siwe';
import { logError } from 'utils/log-error';
import {
subOrganizationIdForEmail,
turnkeySignin,
turnkeySignup,
} from 'utils/turnkey-frontend';
import { verifyAccessCode } from 'utils/accessCode';
type Provider = 'google' | 'github' | 'apple' | 'email';
type Err = { type: 'email' | 'provider'; message: string };
type Props = {
onDone: () => void;
};
export const SignUp = ({ onDone }: Props) => {
const [email, setEmail] = useState('');
const [error, setError] = useState<Err | null>();
const [provider, setProvider] = useState<Provider | false>(false);
const { toast } = useToast();
const snowball = useSnowball();
async function handleSignupRedirect() {
let wallet: PKPEthersWallet | undefined;
const { google } = snowball.auth;
if (google.canHandleOAuthRedirectBack()) {
setProvider('google');
try {
await google.handleOAuthRedirectBack();
// @ts-ignore
wallet = await google.getEthersWallet();
// @ts-ignore
const result = await signInWithEthereum(1, 'signup', wallet);
if (result.error) {
setError({ type: 'provider', message: result.error });
setProvider(false);
wallet = undefined;
logError(new Error(result.error));
return;
}
} catch (err: any) {
setError({ type: 'provider', message: err.message });
setProvider(false);
logError(err);
return;
}
}
// if (apple.canHandleOAuthRedirectBack()) {
// setProvider('apple');
// try {
// await apple.handleOAuthRedirectBack();
// wallet = await apple.getEthersWallet();
// const result = await signInWithEthereum(1, 'signup', wallet);
// if (result.error) {
// setError({ type: 'provider', message: result.error });
// setProvider(false);
// wallet = undefined;
// return;
// }
// } catch (err: any) {
// setError({ type: 'provider', message: err.message });
// setProvider(false);
// return;
// }
// }
if (wallet) {
onDone();
}
}
async function authEmail() {
setProvider('email');
try {
const orgId = await subOrganizationIdForEmail(email);
console.log('orgId', orgId);
if (orgId) {
await turnkeySignin(orgId);
window.location.href = '/dashboard';
} else {
await turnkeySignup(email);
onDone();
}
} catch (err: any) {
setError({ type: 'email', message: err.message });
}
}
useEffect(() => {
handleSignupRedirect();
}, []);
const loading = provider;
const emailValid = /.@./.test(email);
useEffect(() => {
const validateAccessCode = async () => {
const accessCode = localStorage.getItem('accessCode');
if (!accessCode) {
redirectToSignup();
return;
}
try {
await verifyAccessCode(accessCode);
} catch (err: any) {
redirectToSignup();
}
};
const redirectToSignup = () => {
localStorage.removeItem('accessCode');
window.location.href = '/signup';
};
validateAccessCode();
}, []);
return (
<div>
<div className="self-stretch p-3 xs:p-6 flex-col justify-center items-center gap-5 flex">
<div className="self-stretch text-center text-sky-950 text-2xl font-medium font-display leading-tight">
Sign up to Snowball
</div>
</div>
<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 flex-col justify-center items-center gap-3 flex">
<Button
leftIcon={loading && loading === 'google' ? null : <GoogleIcon />}
rightIcon={
loading && loading === 'google' ? (
<LoaderIcon className="animate-spin" />
) : null
}
onClick={() => {
setProvider('google');
snowball.auth.google.startOAuthRedirect();
}}
className="flex-1 self-stretch"
variant={'tertiary'}
disabled={!!loading}
>
Continue with Google
</Button>
<Button
leftIcon={<GithubIcon />}
rightIcon={
loading && loading === 'github' ? (
<LoaderIcon className="animate-spin" />
) : null
}
onClick={async () => {
setProvider('github');
await new Promise((resolve) => setTimeout(resolve, 800));
setProvider(false);
toast({
id: 'coming-soon',
title: 'Sign-in with GitHub is coming soon!',
variant: 'info',
onDismiss() {},
});
}}
className="flex-1 self-stretch"
variant={'tertiary'}
disabled={!!loading}
>
Continue with GitHub
</Button>
<Button
leftIcon={<AppleIcon />}
rightIcon={
loading && loading === 'apple' ? (
<LoaderIcon className="animate-spin text-white" />
) : null
}
onClick={async () => {
setProvider('apple');
// snowball.auth.apple.startOAuthRedirect();
await new Promise((resolve) => setTimeout(resolve, 800));
setProvider(false);
toast({
id: 'coming-soon',
title: 'Sign-in with Apple is coming soon!',
variant: 'info',
onDismiss() {},
});
}}
className={`flex-1 self-stretch border-black enabled:bg-black text-white ${
loading && loading === 'apple' ? 'disabled:bg-black' : ''
}`}
variant={'tertiary'}
disabled={!!loading}
>
Continue with Apple
</Button>
</div>
{error && error.type === 'provider' && (
<div className="-mt-3 justify-center items-center inline-flex">
<div className="text-red-500 text-sm">Error: {error.message}</div>
</div>
)}
<div className="self-stretch justify-start items-center gap-8 inline-flex">
<DotBorder className="flex-1" />
<div className="text-center text-slate-400 text-xs font-normal font-['JetBrains Mono'] leading-none">
OR
</div>
<DotBorder className="flex-1" />
</div>
<div className="self-stretch flex-col gap-8 flex">
<div className="flex-col justify-start items-start gap-2 inline-flex">
<div className="text-sky-950 text-sm font-normal font-['Inter'] leading-tight">
Email
</div>
<Input
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={!!loading}
/>
</div>
<Button
rightIcon={
loading && loading === 'email' ? (
<LoaderIcon className="animate-spin" />
) : (
<ArrowRightCircleFilledIcon height="16" />
)
}
onClick={() => {
authEmail();
}}
variant={'secondary'}
disabled={!email || !emailValid || !!loading}
>
Continue with Email
</Button>
<div className="flex flex-col gap-3">
{error && error.type === 'email' && (
<div className="justify-center items-center gap-2 inline-flex">
<div className="text-red-500 text-sm">
Error: {error.message}
</div>
</div>
)}
<div className="justify-center items-center gap-2 inline-flex">
<div className="text-center text-slate-600 text-sm font-normal font-['Inter'] leading-tight">
Already an user?
</div>
<div className="justify-center items-center gap-1.5 flex">
<Link
to="/login"
className="text-sky-950 text-sm font-normal font-['Inter'] underline leading-tight"
>
Sign in now
</Link>
</div>
</div>
</div>
</div>
</div>
</div>
);
};

View File

@ -1,49 +0,0 @@
import React, { useEffect, useState } from 'react';
import { snowball } from 'utils/use-snowball';
import { Login } from './Login';
import { Done } from './Done';
import { AccessSignUp } from './AccessSignUp';
type Screen = 'login' | 'signup' | 'success';
const DASHBOARD_URL = '/';
export const SnowballAuth: React.FC = () => {
const path = window.location.pathname;
const [screen, setScreen] = useState<Screen>(
path === '/login' ? 'login' : 'signup',
);
useEffect(() => {
if (snowball.session) {
window.location.href = DASHBOARD_URL;
}
}, []);
useEffect(() => {
if (path === '/login') {
setScreen('login');
} else if (path === '/signup') {
setScreen('signup');
}
}, [path]);
if (screen === 'signup') {
return (
<AccessSignUp
onDone={() => {
setScreen('success');
}}
/>
);
}
if (screen === 'login') {
return (
<Login />
);
}
if (screen === 'success') {
return <Done continueTo={DASHBOARD_URL} />;
}
};