forked from cerc-io/snowballtools-base
Turnkey auth
This commit is contained in:
@@ -36,6 +36,9 @@
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@turnkey/http": "^2.10.0",
|
||||
"@turnkey/sdk-react": "^0.1.0",
|
||||
"@turnkey/webauthn-stamper": "^0.5.0",
|
||||
"@walletconnect/ethereum-provider": "^2.12.2",
|
||||
"@web3modal/siwe": "^4.0.5",
|
||||
"@web3modal/wagmi": "^4.0.5",
|
||||
@@ -83,4 +86,4 @@
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.2.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import { DotBorder } from 'components/shared/DotBorder';
|
||||
import { WavyBorder } from 'components/shared/WavyBorder';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSnowball } from 'utils/use-snowball';
|
||||
import { CreatePasskey } from './CreatePasskey';
|
||||
import { Input } from 'components/shared/Input';
|
||||
import { AppleIcon } from 'components/shared/CustomIcon/AppleIcon';
|
||||
import { Link } from 'react-router-dom';
|
||||
@@ -17,6 +16,11 @@ 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';
|
||||
|
||||
type Provider = 'google' | 'github' | 'apple' | 'email';
|
||||
|
||||
@@ -81,6 +85,23 @@ export const SignUp = ({ onDone }: Props) => {
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}, []);
|
||||
@@ -88,10 +109,6 @@ export const SignUp = ({ onDone }: Props) => {
|
||||
const loading = provider;
|
||||
const emailValid = /.@./.test(email);
|
||||
|
||||
if (provider === 'email') {
|
||||
return <CreatePasskey onDone={onDone} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="self-stretch p-3 xs:p-6 flex-col justify-center items-center gap-5 flex">
|
||||
@@ -200,9 +217,15 @@ export const SignUp = ({ onDone }: Props) => {
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
rightIcon={<ArrowRightCircleFilledIcon height="16" />}
|
||||
rightIcon={
|
||||
loading && loading === 'email' ? (
|
||||
<LoaderIcon className="animate-spin" />
|
||||
) : (
|
||||
<ArrowRightCircleFilledIcon height="16" />
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
setProvider('email');
|
||||
authEmail();
|
||||
}}
|
||||
variant={'secondary'}
|
||||
disabled={!email || !emailValid || !!loading}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { TurnkeyClient, getWebAuthnAttestation } from '@turnkey/http';
|
||||
import { WebauthnStamper } from '@turnkey/webauthn-stamper';
|
||||
|
||||
const baseUrl = import.meta.env.VITE_SERVER_URL;
|
||||
|
||||
const PASSKEY_WALLET_RPID = import.meta.env.VITE_PASSKEY_WALLET_RPID!;
|
||||
const TURNKEY_BASE_URL = import.meta.env.VITE_TURNKEY_API_BASE_URL!;
|
||||
|
||||
// All algorithms can be found here: https://www.iana.org/assignments/cose/cose.xhtml#algorithms
|
||||
// We only support ES256, which is listed here
|
||||
const es256 = -7;
|
||||
|
||||
export async function subOrganizationIdForEmail(
|
||||
email: string,
|
||||
): Promise<string | null> {
|
||||
const res = await fetch(`${baseUrl}/auth/registration/${email}`);
|
||||
|
||||
// If API returns a non-empty 200, this email maps to an existing user.
|
||||
if (res.status == 200) {
|
||||
return (await res.json()).subOrganizationId;
|
||||
} else if (res.status === 204) {
|
||||
return null;
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unexpected response from registration status endpoint: ${res.status}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This signup function triggers a webauthn "create" ceremony and POSTs the resulting attestation to the backend
|
||||
* The backend uses Turnkey to create a brand new sub-organization with a new private key.
|
||||
* @param email user email
|
||||
*/
|
||||
export async function turnkeySignup(email: string) {
|
||||
const challenge = generateRandomBuffer();
|
||||
const authenticatorUserId = generateRandomBuffer();
|
||||
|
||||
// An example of possible options can be found here:
|
||||
// https://www.w3.org/TR/webauthn-2/#sctn-sample-registration
|
||||
const attestation = await getWebAuthnAttestation({
|
||||
publicKey: {
|
||||
rp: {
|
||||
id: PASSKEY_WALLET_RPID,
|
||||
name: 'Demo Passkey Wallet',
|
||||
},
|
||||
challenge,
|
||||
pubKeyCredParams: [
|
||||
{
|
||||
// This constant designates the type of credential we want to create.
|
||||
// The enum only supports one value, "public-key"
|
||||
// https://www.w3.org/TR/webauthn-2/#enumdef-publickeycredentialtype
|
||||
type: 'public-key',
|
||||
alg: es256,
|
||||
},
|
||||
],
|
||||
user: {
|
||||
id: authenticatorUserId,
|
||||
name: email,
|
||||
displayName: email,
|
||||
},
|
||||
authenticatorSelection: {
|
||||
requireResidentKey: true,
|
||||
residentKey: 'required',
|
||||
userVerification: 'preferred',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = await fetch(`${baseUrl}/auth/register`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
attestation,
|
||||
challenge: base64UrlEncode(challenge),
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error(
|
||||
`Unexpected response from registration endpoint: ${res.status}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// In order to know whether the user is logged in for `subOrganizationId`, we make them sign
|
||||
// a request for Turnkey's "whoami" endpoint.
|
||||
// The backend will then forward to Turnkey and get a response on whether the stamp was valid.
|
||||
// If this is successful, our backend will issue a logged in session.
|
||||
export async function turnkeySignin(subOrganizationId: string) {
|
||||
const stamper = new WebauthnStamper({
|
||||
rpId: PASSKEY_WALLET_RPID,
|
||||
});
|
||||
const client = new TurnkeyClient(
|
||||
{
|
||||
baseUrl: TURNKEY_BASE_URL,
|
||||
},
|
||||
stamper,
|
||||
);
|
||||
|
||||
var signedRequest;
|
||||
try {
|
||||
signedRequest = await client.stampGetWhoami({
|
||||
organizationId: subOrganizationId,
|
||||
});
|
||||
} catch (e) {
|
||||
throw new Error(`Error during webauthn prompt: ${e}`);
|
||||
}
|
||||
|
||||
const res = await fetch(`${baseUrl}/auth/authenticate`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
signedWhoamiRequest: signedRequest,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error(
|
||||
`Unexpected response from authentication endpoint: ${res.status}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const generateRandomBuffer = (): ArrayBuffer => {
|
||||
const arr = new Uint8Array(32);
|
||||
crypto.getRandomValues(arr);
|
||||
return arr.buffer;
|
||||
};
|
||||
|
||||
const base64UrlEncode = (challenge: ArrayBuffer): string => {
|
||||
return Buffer.from(challenge)
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
};
|
||||
Reference in New Issue
Block a user