From 48552310e02a5581be5cbd3e34bd385d9b908e56 Mon Sep 17 00:00:00 2001 From: Gilbert Date: Mon, 6 May 2024 14:36:33 -0500 Subject: [PATCH] Turnkey auth --- packages/backend/package.json | 3 +- packages/backend/src/config.ts | 6 + packages/backend/src/entity/User.ts | 6 + packages/backend/src/routes/auth.ts | 59 +++---- packages/backend/src/server.ts | 8 +- packages/backend/src/service.ts | 58 ++++--- packages/backend/src/turnkey-backend.ts | 130 ++++++++++++++++ packages/frontend/package.json | 5 +- packages/frontend/src/pages/auth/SignUp.tsx | 37 ++++- .../frontend/src/utils/turnkey-frontend.ts | 144 ++++++++++++++++++ yarn.lock | 39 ++++- 11 files changed, 430 insertions(+), 65 deletions(-) create mode 100644 packages/backend/src/turnkey-backend.ts create mode 100644 packages/frontend/src/utils/turnkey-frontend.ts diff --git a/packages/backend/package.json b/packages/backend/package.json index f0fd6fbc..fdd5b247 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -3,10 +3,11 @@ "version": "1.0.0", "main": "index.js", "dependencies": { - "@snowballtools/laconic-sdk": "^0.1.17", "@graphql-tools/schema": "^10.0.2", "@graphql-tools/utils": "^10.0.12", "@octokit/oauth-app": "^6.1.0", + "@snowballtools/laconic-sdk": "^0.1.17", + "@turnkey/sdk-server": "^0.1.0", "@types/debug": "^4.1.5", "@types/express": "^4.17.21", "@types/node": "^20.11.0", diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index b4cdd8fc..2f5f1da9 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -52,4 +52,10 @@ export interface Config { gitHub: GitHubConfig; registryConfig: RegistryConfig; misc: MiscConfig; + turnkey: { + apiBaseUrl: string; + apiPublicKey: string; + apiPrivateKey: string; + defaultOrganizationId: string; + }; } diff --git a/packages/backend/src/entity/User.ts b/packages/backend/src/entity/User.ts index 69700fab..a4299245 100644 --- a/packages/backend/src/entity/User.ts +++ b/packages/backend/src/entity/User.ts @@ -39,6 +39,12 @@ export class User { @CreateDateColumn() updatedAt!: Date; + @Column() + subOrgId!: string; + + @Column() + turnkeyWalletId!: string; + @OneToMany(() => ProjectMember, (projectMember) => projectMember.project, { cascade: ['soft-remove'] }) diff --git a/packages/backend/src/routes/auth.ts b/packages/backend/src/routes/auth.ts index e1214bbc..e704b543 100644 --- a/packages/backend/src/routes/auth.ts +++ b/packages/backend/src/routes/auth.ts @@ -1,45 +1,50 @@ import { Router } from 'express'; import { SiweMessage } from 'siwe'; import { Service } from '../service'; +import { authenticateUser, createUser } from '../turnkey-backend'; const router = Router(); -router.post('/validate', async (req, res) => { - const { message, signature, action } = req.body; - const { success, data } = await new SiweMessage(message).verify({ - signature, - }); - - if (!success) { - return res.send({ success }); - } - +router.get('/registration/:email', async (req, res) => { const service: Service = req.app.get('service'); - const user = await service.getUserByEthAddress(data.address); - - if (action === 'signup') { - if (user) { - return res.send({ success: false, error: 'user_already_exists' }); - } - const newUser = await service.loadOrCreateUser(data.address); - req.session.userId = newUser.id; - } else if (action === 'login') { - if (!user) { - return res.send({ success: false, error: 'user_not_found' }); - } - req.session.userId = user.id; + const user = await service.getUserByEmail(req.params.email); + if (user) { + return res.send({ subOrganizationId: user?.subOrgId }); + } else { + return res.sendStatus(204); } +}); - req.session.address = data.address; +router.post('/register', async (req, res) => { + const { email, challenge, attestation } = req.body; + const user = await createUser(req.app.get('service'), { + challenge, + attestation, + userEmail: email, + userName: email.split('@')[0], + }); + req.session.userId = user.id; + res.sendStatus(200); +}); - res.send({ success }); +router.post('/authenticate', async (req, res) => { + const { signedWhoamiRequest } = req.body; + const user = await authenticateUser( + req.app.get('service'), + signedWhoamiRequest, + ); + if (user) { + req.session.userId = user.id; + res.sendStatus(200); + } else { + res.sendStatus(401); + } }); router.get('/session', (req, res) => { - if (req.session.address) { + if (req.session.userId) { res.send({ userId: req.session.userId, - address: req.session.address, }); } else { res.status(401).send({ error: 'Unauthorized: No active session' }); diff --git a/packages/backend/src/server.ts b/packages/backend/src/server.ts index fa8b7d46..74f7e5ed 100644 --- a/packages/backend/src/server.ts +++ b/packages/backend/src/server.ts @@ -24,7 +24,6 @@ const log = debug('snowball:server'); declare module 'express-session' { interface SessionData { userId: string; - address: string; } } @@ -54,14 +53,13 @@ export const createAndStartServer = async ( context: async ({ req }) => { // https://www.apollographql.com/docs/apollo-server/v3/security/authentication#api-wide-authorization - const { address } = req.session; + const { userId } = req.session; - if (!address) { + if (!userId) { throw new AuthenticationError('Unauthorized: No active session'); } - // Find/create user from ETH address in request session - const user = await service.loadOrCreateUser(address); + const user = await service.getUser(userId); return { user }; }, diff --git a/packages/backend/src/service.ts b/packages/backend/src/service.ts index ff304fde..bfee7702 100644 --- a/packages/backend/src/service.ts +++ b/packages/backend/src/service.ts @@ -161,6 +161,22 @@ export class Service { }); } + async getUserByEmail(email: string): Promise { + return await this.db.getUser({ + where: { + email + } + }); + } + + async getUserBySubOrgId(subOrgId: string): Promise { + return await this.db.getUser({ + where: { + subOrgId + } + }); + } + async getUserByEthAddress (ethAddress: string): Promise { return await this.db.getUser({ where: { @@ -169,28 +185,30 @@ export class Service { }); } - async loadOrCreateUser (ethAddress: string): Promise { - // Get user by ETH address - let user = await this.getUserByEthAddress(ethAddress); + async createUser (params: { + name: string + email: string + subOrgId: string + ethAddress: string + turnkeyWalletId: string + }): Promise { + const [org] = await this.db.getOrganizations({}); + assert(org, 'No organizations exists in database'); - if (!user) { - const [org] = await this.db.getOrganizations({}); - assert(org, 'No organizations exists in database'); + // Create user with new address + const user = await this.db.addUser({ + email: params.email, + name: params.name, + subOrgId: params.subOrgId, + ethAddress: params.ethAddress, + isVerified: true, + }); - // Create user with new address - user = await this.db.addUser({ - email: `${ethAddress}@example.com`, - name: ethAddress, - isVerified: true, - ethAddress - }); - - await this.db.addUserOrganization({ - member: user, - organization: org, - role: Role.Owner - }); - } + await this.db.addUserOrganization({ + member: user, + organization: org, + role: Role.Owner + }); return user; } diff --git a/packages/backend/src/turnkey-backend.ts b/packages/backend/src/turnkey-backend.ts new file mode 100644 index 00000000..541f76ff --- /dev/null +++ b/packages/backend/src/turnkey-backend.ts @@ -0,0 +1,130 @@ +import { Turnkey, TurnkeyApiTypes } from '@turnkey/sdk-server'; + +// Default path for the first Ethereum address in a new HD wallet. +// See https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki, paths are in the form: +// m / purpose' / coin_type' / account' / change / address_index +// - Purpose is a constant set to 44' following the BIP43 recommendation. +// - Coin type is set to 60 (ETH) -- see https://github.com/satoshilabs/slips/blob/master/slip-0044.md +// - Account, Change, and Address Index are set to 0 +import { DEFAULT_ETHEREUM_ACCOUNTS } from '@turnkey/sdk-server'; +import { getConfig } from './utils'; +import { Service } from './service'; + +type TAttestation = TurnkeyApiTypes['v1Attestation']; + +type CreateUserParams = { + userName: string; + userEmail: string; + challenge: string; + attestation: TAttestation; +}; + +export async function createUser( + service: Service, + { userName, userEmail, challenge, attestation }: CreateUserParams, +) { + try { + if (await service.getUserByEmail(userEmail)) { + throw new Error(`User already exists: ${userEmail}`); + } + + const config = await getConfig(); + const turnkey = new Turnkey(config.turnkey); + + const apiClient = turnkey.api(); + + const walletName = `Default ETH Wallet`; + + const createSubOrgResponse = await apiClient.createSubOrganization({ + subOrganizationName: `Default SubOrg for ${userEmail}`, + rootQuorumThreshold: 1, + rootUsers: [ + { + userName, + userEmail, + apiKeys: [], + authenticators: [ + { + authenticatorName: 'Passkey', + challenge, + attestation, + }, + ], + }, + ], + wallet: { + walletName: walletName, + accounts: DEFAULT_ETHEREUM_ACCOUNTS, + }, + }); + + const subOrgId = refineNonNull(createSubOrgResponse.subOrganizationId); + const wallet = refineNonNull(createSubOrgResponse.wallet); + + const result = { + id: wallet.walletId, + address: wallet.addresses[0], + subOrgId: subOrgId, + }; + console.log('Turnkey success', result); + + const user = await service.createUser({ + name: userName, + email: userEmail, + subOrgId, + ethAddress: wallet.addresses[0], + turnkeyWalletId: wallet.walletId, + }); + console.log('New user', user); + + return user; + } catch (e) { + console.error('Failed to create user:', e); + throw e; + } +} + +export async function authenticateUser( + service: Service, + signedWhoamiRequest: { + url: string; + body: any; + stamp: { + stampHeaderName: string; + stampHeaderValue: string; + }; + }, +) { + try { + const tkRes = await fetch(signedWhoamiRequest.url, { + method: 'POST', + body: signedWhoamiRequest.body, + headers: { + [signedWhoamiRequest.stamp.stampHeaderName]: + signedWhoamiRequest.stamp.stampHeaderValue, + }, + }); + console.log('AUTH RESULT', tkRes.status); + if (tkRes.status !== 200) { + console.log(await tkRes.text()); + return null; + } + const orgId = (await tkRes.json()).organizationId; + const user = await service.getUserBySubOrgId(orgId); + return user; + } catch (e) { + console.error('Failed to authenticate:', e); + throw e; + } +} + +function refineNonNull( + input: T | null | undefined, + errorMessage?: string, +): T { + if (input == null) { + throw new Error(errorMessage ?? `Unexpected ${JSON.stringify(input)}`); + } + + return input; +} diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 5d915630..1484e705 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -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" } -} +} \ No newline at end of file diff --git a/packages/frontend/src/pages/auth/SignUp.tsx b/packages/frontend/src/pages/auth/SignUp.tsx index fa4fd4b7..eb945fcc 100644 --- a/packages/frontend/src/pages/auth/SignUp.tsx +++ b/packages/frontend/src/pages/auth/SignUp.tsx @@ -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 ; - } - return (
@@ -200,9 +217,15 @@ export const SignUp = ({ onDone }: Props) => { />