Turnkey auth

This commit is contained in:
Gilbert
2024-05-06 14:36:58 -05:00
parent c82e1110d3
commit 48552310e0
11 changed files with 430 additions and 65 deletions
+4 -1
View File
@@ -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"
}
}
}
+30 -7
View File
@@ -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, '');
};