diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index aa61b737a5..34e0c7f40b 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -21,13 +21,15 @@ jobs: run: npm install -g pnpm && pnpm install - name: Install Playwright Browsers run: npx playwright install --with-deps + working-directory: examples/next - name: Run Playwright tests run: npx playwright test + working-directory: examples/next env: BASE_URL: ${{ github.event.client_payload.url }} - uses: actions/upload-artifact@v4 if: ${{ !cancelled() }} with: name: playwright-report - path: playwright-report/ + path: examples/next/playwright-report/ retention-days: 30 \ No newline at end of file diff --git a/packages/controller/src/wallets/bridge.ts b/packages/controller/src/wallets/bridge.ts index 8a4476f043..f8e87f50f8 100644 --- a/packages/controller/src/wallets/bridge.ts +++ b/packages/controller/src/wallets/bridge.ts @@ -1,3 +1,4 @@ +import { getAddress } from "ethers"; import { ArgentWallet } from "./argent"; import { MetaMaskWallet } from "./metamask"; import { PhantomWallet } from "./phantom"; @@ -134,11 +135,12 @@ export class WalletBridge { let wallet: WalletAdapter | undefined; if (typeof identifier === "string") { // this is an address + const checkSummedAddress = getAddress(identifier); + wallet = this.walletAdapters.values().find((adapter) => { - const ident = identifier.toLowerCase(); return ( - adapter.getConnectedAccounts().includes(ident) || - adapter.type === ident + adapter.getConnectedAccounts().includes(checkSummedAddress) || + adapter.type === checkSummedAddress ); }); } else { @@ -149,7 +151,7 @@ export class WalletBridge { wallet = this.walletAdapters .values() .find((adapter) => - adapter.getConnectedAccounts().includes(identifier.toLowerCase()), + adapter.getConnectedAccounts().includes(getAddress(identifier)), ); } diff --git a/packages/controller/src/wallets/index.ts b/packages/controller/src/wallets/index.ts index ac509f2476..0eb74efa14 100644 --- a/packages/controller/src/wallets/index.ts +++ b/packages/controller/src/wallets/index.ts @@ -3,6 +3,5 @@ export * from "./bridge"; export * from "./metamask"; export * from "./phantom"; export * from "./rabby"; -export * from "./turnkey"; export * from "./types"; export * from "./wallet-connect"; diff --git a/packages/controller/src/wallets/metamask/index.ts b/packages/controller/src/wallets/metamask/index.ts index df70d33866..e29c24b133 100644 --- a/packages/controller/src/wallets/metamask/index.ts +++ b/packages/controller/src/wallets/metamask/index.ts @@ -1,4 +1,5 @@ import { MetaMaskSDK } from "@metamask/sdk"; +import { getAddress } from "ethers/address"; import { createStore } from "mipd"; import { ExternalPlatform, @@ -31,14 +32,20 @@ export class MetaMaskWallet implements WalletAdapter { }) .then((accounts: any) => { if (accounts && accounts.length > 0) { - this.account = accounts[0]; - this.connectedAccounts = accounts; + this.account = getAddress(accounts[0]); + this.connectedAccounts = accounts.map((account: string) => + getAddress(account), + ); } }); this.MMSDK.getProvider()?.on("accountsChanged", (accounts: any) => { if (Array.isArray(accounts)) { - this.account = accounts?.[0]; - this.connectedAccounts = accounts; + if (accounts.length > 0) { + this.account = getAddress(accounts?.[0]); + } + this.connectedAccounts = accounts.map((account: string) => + getAddress(account), + ); } }); }); @@ -69,8 +76,8 @@ export class MetaMaskWallet implements WalletAdapter { } async connect(address?: string): Promise> { - if (address && this.connectedAccounts.includes(address)) { - this.account = address; + if (address && this.connectedAccounts.includes(getAddress(address))) { + this.account = getAddress(address); } if (this.account) { @@ -84,8 +91,10 @@ export class MetaMaskWallet implements WalletAdapter { const accounts = await this.MMSDK.connect(); if (accounts && accounts.length > 0) { - this.account = accounts[0]; - this.connectedAccounts = accounts; + this.account = getAddress(accounts[0]); + this.connectedAccounts = accounts.map((account: string) => + getAddress(account), + ); return { success: true, wallet: this.type, account: this.account }; } @@ -141,7 +150,7 @@ export class MetaMaskWallet implements WalletAdapter { const result = await this.MMSDK.getProvider()?.request({ method: "personal_sign", - params: [this.account!, message], + params: [this.account, message], }); return { success: true, wallet: this.type, result }; diff --git a/packages/controller/src/wallets/rabby/index.ts b/packages/controller/src/wallets/rabby/index.ts index 4d6e33e6e5..eef7eed7cd 100644 --- a/packages/controller/src/wallets/rabby/index.ts +++ b/packages/controller/src/wallets/rabby/index.ts @@ -1,3 +1,4 @@ +import { getAddress } from "ethers/address"; import { createStore, EIP6963ProviderDetail } from "mipd"; import { ExternalPlatform, @@ -31,10 +32,8 @@ export class RabbyWallet implements WalletAdapter { this.provider?.provider?.on("accountsChanged", (accounts: string[]) => { if (accounts) { // rabby doesn't allow multiple accounts to be connected at the same time - this.connectedAccounts = accounts.map((account) => - account.toLowerCase(), - ); - this.account = accounts?.[0]?.toLowerCase(); + this.connectedAccounts = accounts.map((account) => getAddress(account)); + this.account = getAddress(accounts?.[0]); } }); } @@ -58,8 +57,8 @@ export class RabbyWallet implements WalletAdapter { } async connect(address?: string): Promise> { - if (address && this.connectedAccounts.includes(address.toLowerCase())) { - this.account = address.toLowerCase(); + if (address && this.connectedAccounts.includes(getAddress(address))) { + this.account = getAddress(address); } if (this.account) { diff --git a/packages/controller/src/wallets/turnkey/index.ts b/packages/controller/src/wallets/turnkey/index.ts deleted file mode 100644 index 4d543a5408..0000000000 --- a/packages/controller/src/wallets/turnkey/index.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { TurnkeyIframeClient } from "@turnkey/sdk-browser"; -import { ethers, getBytes, Signature } from "ethers"; -import { - ExternalPlatform, - ExternalWallet, - ExternalWalletResponse, - ExternalWalletType, - WalletAdapter, -} from "../types"; - -export class TurnkeyWallet implements WalletAdapter { - readonly type: ExternalWalletType = "turnkey" as ExternalWalletType; - readonly platform: ExternalPlatform = "ethereum"; - private account: string | undefined = undefined; - private organizationId: string | undefined = undefined; - - constructor( - private turnkeyIframeClient: TurnkeyIframeClient, - address?: string, - organizationId?: string, - ) { - this.account = address; - this.organizationId = organizationId; - } - - isAvailable(): boolean { - return typeof window !== "undefined"; - } - - getInfo(): ExternalWallet { - const available = this.isAvailable(); - - return { - type: this.type, - available, - name: "Turnkey", - platform: this.platform, - }; - } - - async connect(): Promise> { - if (this.account) { - return { success: true, wallet: this.type, account: this.account }; - } - - try { - if (!this.isAvailable()) { - throw new Error("Turnkey is not available"); - } - - const accounts = await this.turnkeyIframeClient.getWallets(); - if (accounts && accounts.wallets.length > 0) { - const walletAccount = await this.turnkeyIframeClient.getWalletAccount({ - walletId: accounts.wallets[0].walletId, - }); - this.account = walletAccount.account.address; - return { success: true, wallet: this.type, account: this.account }; - } - - throw new Error("No accounts found"); - } catch (error) { - console.error(`Error connecting to Turnkey:`, error); - return { - success: false, - wallet: this.type, - error: (error as Error).message || "Unknown error", - }; - } - } - - getConnectedAccounts(): string[] { - return this.account ? [this.account] : []; - } - - async signTransaction( - transaction: any, - ): Promise> { - try { - if (!this.isAvailable() || !this.account) { - throw new Error("Turnkey is not connected"); - } - - const result = ( - await this.turnkeyIframeClient.signTransaction({ - organizationId: this.organizationId, - signWith: this.account, - unsignedTransaction: transaction, - type: "TRANSACTION_TYPE_ETHEREUM", - }) - ).signedTransaction; - - return { success: true, wallet: this.type, result }; - } catch (error) { - console.error(`Error signing transaction with Turnkey:`, error); - return { - success: false, - wallet: this.type, - error: (error as Error).message || "Unknown error", - }; - } - } - - async signMessage(message: string): Promise> { - try { - if (!this.isAvailable() || !this.account) { - throw new Error("Turnkey is not connected"); - } - - const paddedMessage = `0x${message.replace("0x", "").padStart(64, "0")}`; - const messageBytes = getBytes(paddedMessage); - const messageHash = ethers.hashMessage(messageBytes); - - const { r, s, v } = await this.turnkeyIframeClient.signRawPayload({ - organizationId: this.organizationId, - signWith: this.account, - payload: messageHash, - encoding: "PAYLOAD_ENCODING_HEXADECIMAL", - hashFunction: "HASH_FUNCTION_NO_OP", - }); - - const rHex = r.startsWith("0x") ? r : "0x" + r; - const sHex = s.startsWith("0x") ? s : "0x" + s; - - const vNumber = parseInt(v, 16); - - if (isNaN(vNumber)) { - console.error(`Invalid recovery ID (v) received from Turnkey: ${v}`); - throw new Error(`Invalid recovery ID (v) received: ${v}`); - } - - const signature = Signature.from({ - r: rHex, - s: sHex, - v: vNumber, - }); - - return { - success: true, - wallet: this.type, - result: signature.serialized, - account: this.account, - }; - } catch (error) { - console.error(`Error signing message with Turnkey:`, error); - return { - success: false, - wallet: this.type, - error: (error as Error).message || "Unknown error", - }; - } - } - - async signTypedData(data: any): Promise> { - return this.signMessage(data); - } - - async sendTransaction(_txn: any): Promise> { - return { - success: false, - wallet: this.type, - error: "Not implemented", - }; - } - - async switchChain(_chainId: string): Promise { - return false; - } - - async getBalance( - tokenAddress?: string, - ): Promise> { - try { - if (!this.isAvailable() || !this.account) { - throw new Error("Turnkey is not connected"); - } - - if (tokenAddress) { - return { - success: false, - wallet: this.type, - error: "Not implemented for ERC20", - }; - } else { - return { success: true, wallet: this.type, result: "0" }; - } - } catch (error) { - console.error(`Error getting balance from Turnkey:`, error); - return { - success: false, - wallet: this.type, - error: (error as Error).message || "Unknown error", - }; - } - } -} diff --git a/packages/controller/src/wallets/wallet-connect/index.ts b/packages/controller/src/wallets/wallet-connect/index.ts index 2aca5dcd39..21f5e1b61d 100644 --- a/packages/controller/src/wallets/wallet-connect/index.ts +++ b/packages/controller/src/wallets/wallet-connect/index.ts @@ -1,4 +1,5 @@ import Provider from "@walletconnect/ethereum-provider"; +import { getAddress } from "ethers/address"; import { ExternalPlatform, ExternalWallet, @@ -17,7 +18,7 @@ export class WalletConnectWallet implements WalletAdapter { private provider: Provider, address?: string, ) { - this.account = address?.toLowerCase(); + this.account = address ? getAddress(address) : undefined; } getConnectedAccounts(): string[] { diff --git a/packages/keychain/package.json b/packages/keychain/package.json index 7f17bd07a6..f8f127a0c8 100644 --- a/packages/keychain/package.json +++ b/packages/keychain/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "@auth0/auth0-react": "^2.3.0", + "@auth0/auth0-spa-js": "^2.1.3", "@cartridge/controller-wasm": "catalog:", "@cartridge/controller": "workspace:*", "@cartridge/penpal": "catalog:", @@ -32,7 +33,7 @@ "@starknet-io/types-js": "catalog:", "@stripe/react-stripe-js": "^2.8.1", "@stripe/stripe-js": "^4.8.0", - "@turnkey/sdk-browser": "^4.0.0", + "@turnkey/sdk-browser": "^4.1.0", "@turnkey/sdk-react": "^4.2.1", "@walletconnect/ethereum-provider": "^2.20.0", "base64url": "catalog:", diff --git a/packages/keychain/src/components/connect/create/social/api.ts b/packages/keychain/src/components/connect/create/social/api.ts deleted file mode 100644 index 44b5fa7aa0..0000000000 --- a/packages/keychain/src/components/connect/create/social/api.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { fetchApiCreator } from "@cartridge/ui/utils"; -import { TurnkeyIframeClient } from "@turnkey/sdk-browser"; -import { getIframePublicKey } from "./index"; - -export const SOCIAL_PROVIDER_NAME = "discord"; - -export const fetchApi = fetchApiCreator( - `${import.meta.env.VITE_CARTRIDGE_API_URL}/oauth2`, - { - credentials: "same-origin", - }, -); - -export const getTurnkeySuborg = async ( - oidcToken: string, -): Promise => { - const getSuborgsResponse = await fetchApi("suborgs", { - filterType: "OIDC_TOKEN", - filterValue: oidcToken, - }); - if (!getSuborgsResponse) { - throw new Error("No suborgs response found"); - } - - if (getSuborgsResponse.organizationIds.length > 1) { - throw new Error("Multiple suborgs found"); - } - - return getSuborgsResponse.organizationIds[0]; -}; - -export const getOrCreateTurnkeySuborg = async ( - oidcToken: string, - username: string, -) => { - const getSuborgsResponse = await fetchApi("suborgs", { - filterType: "OIDC_TOKEN", - filterValue: oidcToken, - }); - if (!getSuborgsResponse) { - throw new Error("No suborgs response found"); - } - - let targetSubOrgId: string; - if (getSuborgsResponse.organizationIds.length === 0) { - const createSuborgResponse = await fetchApi( - "create-suborg", - { - rootUserUsername: username, - oauthProviders: [{ providerName: SOCIAL_PROVIDER_NAME, oidcToken }], - }, - ); - targetSubOrgId = createSuborgResponse.subOrganizationId; - } else if (getSuborgsResponse.organizationIds.length === 1) { - targetSubOrgId = getSuborgsResponse.organizationIds[0]; - } else { - if (import.meta.env.DEV) { - targetSubOrgId = getSuborgsResponse.organizationIds[0]; - } else { - // We don't want to handle multiple suborgs per user at the moment - throw new Error("Multiple suborgs found for user"); - } - } - - return targetSubOrgId; -}; - -export const authenticateToTurnkey = async ( - subOrgId: string, - oidcToken: string, - authIframeClient: TurnkeyIframeClient, -) => { - const iframePublicKey = await getIframePublicKey(authIframeClient); - - const authResponse = await fetchApi( - `auth`, - { - suborgID: subOrgId, - targetPublicKey: iframePublicKey, - oidcToken, - invalidateExisting: true, - }, - { - client_id: "turnkey", - }, - ); - - const injectResponse = await authIframeClient.injectCredentialBundle( - authResponse.credentialBundle, - ); - if (!injectResponse) { - throw new Error("Failed to inject credentials into Turnkey"); - } -}; - -type GetSuborgsResponse = { - organizationIds: string[]; -}; - -type CreateSuborgResponse = { - subOrganizationId: string; -}; - -type AuthResponse = { - credentialBundle: string; -}; diff --git a/packages/keychain/src/components/connect/create/social/auth0.ts b/packages/keychain/src/components/connect/create/social/auth0.ts deleted file mode 100644 index bea1ce7622..0000000000 --- a/packages/keychain/src/components/connect/create/social/auth0.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { IdToken } from "@auth0/auth0-react"; -import { jwtDecode, JwtPayload } from "jwt-decode"; - -export const getOidcToken = async ( - getIdTokenClaims: () => Promise, - expectedNonce: string, -) => { - const tokenClaims = await getIdTokenClaims(); - if (!tokenClaims) { - throw new Error("Not authenticated with Auth0 yet"); - } - - const oidcTokenString = tokenClaims.__raw; - if (!oidcTokenString) { - throw new Error("Raw ID token string (__raw) not found in claims"); - } - - const decodedToken = jwtDecode(oidcTokenString); - if (!decodedToken.tknonce) { - return undefined; - } - - if (decodedToken.tknonce !== expectedNonce) { - throw new Error( - `Nonce mismatch: expected ${expectedNonce}, got ${decodedToken.tknonce}`, - ); - } - return tokenClaims.__raw; -}; - -interface DecodedIdToken extends JwtPayload { - nonce?: string; - tknonce?: string; -} diff --git a/packages/keychain/src/components/connect/create/social/index.ts b/packages/keychain/src/components/connect/create/social/index.ts index d04251838d..c097cd753d 100644 --- a/packages/keychain/src/components/connect/create/social/index.ts +++ b/packages/keychain/src/components/connect/create/social/index.ts @@ -1,352 +1,35 @@ -import { useAuth0 } from "@auth0/auth0-react"; -import { TurnkeyWallet } from "@cartridge/controller"; -import { sha256 } from "@noble/hashes/sha2"; -import { bytesToHex } from "@noble/hashes/utils"; -import { TurnkeyIframeClient } from "@turnkey/sdk-browser"; -import { useTurnkey } from "@turnkey/sdk-react"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { LoginResponse, SignupResponse } from "../useCreateController"; -import { - authenticateToTurnkey, - getOrCreateTurnkeySuborg, - getTurnkeySuborg, - SOCIAL_PROVIDER_NAME, -} from "./api"; -import { getOidcToken } from "./auth0"; -import { getOrCreateWallet, getWallet } from "./turnkey"; +import { ExternalWalletResponse, WalletAdapter } from "@cartridge/controller"; +import { useCallback } from "react"; export const useSocialAuthentication = ( - setChangeWallet: (changeWallet: boolean) => void, + setChangeWallet?: (changeWallet: boolean) => void, ) => { - const [signupOrLogin, setSignupOrLogin] = useState< - { username: string } | { address: string } | undefined - >(undefined); - const { authIframeClient } = useTurnkey(); - const { - loginWithPopup, - getIdTokenClaims, - isAuthenticated, - user, - error, - logout, - isLoading, - } = useAuth0(); - - const authIframeClientRef = useRef(authIframeClient); - const signerPromiseRef = useRef<{ - resolve: ( - value: - | SignupResponse - | LoginResponse - | undefined - | PromiseLike, - ) => void; - reject: (reason?: Error) => void; - } | null>(null); - - const resetState = useCallback(async () => { - setSignupOrLogin(undefined); - signerPromiseRef.current = null; - }, [setSignupOrLogin]); - - useEffect(() => { - authIframeClientRef.current = authIframeClient; - }, [authIframeClient]); - - useEffect(() => { - if ( - error && - (error.message.includes("Popup closed") || - error.message.includes( - "The resource owner or authorization server denied the request", - ) || - error.message.includes("rate limited")) && - signerPromiseRef.current - ) { - signerPromiseRef.current.reject( - new Error("Could not sign in with social provider: " + error.message), - ); - console.log("setting signer promise to null2", error); - signerPromiseRef.current = null; - } - }, [error]); - - const resolveForAccountChange = useCallback(() => { - setChangeWallet(true); - signerPromiseRef.current?.resolve(undefined); - resetState(); - }, [setChangeWallet, resetState]); - - const internalSignup = useCallback(async () => { - const username = (signupOrLogin as { username: string }).username; - if (username.length === 0) { - throw new Error("Username not found"); - } - - const iFramePublicKey = await getIframePublicKey( - authIframeClientRef.current!, - ); - - const iFrameNonce = getNonce(iFramePublicKey); - const oidcTokenString = await getOidcToken(getIdTokenClaims, iFrameNonce); - if (!oidcTokenString) { - return; - } - - const subOrganizationId = await getOrCreateTurnkeySuborg( - oidcTokenString, - username, - ); - - await authenticateToTurnkey( - subOrganizationId, - oidcTokenString, - authIframeClientRef.current!, - ); - - const address = await getOrCreateWallet( - subOrganizationId, - username, - authIframeClientRef.current!, - ); - - if (window.keychain_wallets) { - window.keychain_wallets.addEmbeddedWallet( - address.toLowerCase(), - new TurnkeyWallet( - authIframeClientRef.current!, - address, - subOrganizationId, - ), - ); - } else { - throw new Error("No keychain_wallets found"); - } - - if (signerPromiseRef.current) { - signerPromiseRef.current.resolve({ - address, - signer: { - eip191: { - address, - }, - }, - type: "discord", - }); - resetState(); - } else { - console.error("No signer promise found"); - } - }, [signupOrLogin, getIdTokenClaims, resetState]); - - const internalLogin = useCallback(async () => { - const signerAddress = (signupOrLogin as { address: string }).address; - if (!signerAddress) { - throw new Error("Signer address is required"); - } - - const iFramePublicKey = - await authIframeClientRef.current!.getEmbeddedPublicKey(); - if (!iFramePublicKey) { - await resetIframePublicKey(authIframeClientRef.current!); - throw new Error("No iFrame public key, please try again"); - } - - const oidcTokenString = await getOidcToken( - getIdTokenClaims, - getNonce(iFramePublicKey), - ); - - if (!oidcTokenString) { - return; - } - - const subOrganizationId = await getTurnkeySuborg(oidcTokenString); - if (!subOrganizationId) { - resolveForAccountChange(); - return; - } - - await authenticateToTurnkey( - subOrganizationId, - oidcTokenString, - authIframeClientRef.current!, - ); - - const address = await getWallet( - subOrganizationId, - authIframeClientRef.current!, - ); - if (BigInt(address) !== BigInt(signerAddress)) { - resolveForAccountChange(); - return; - } - - if (window.keychain_wallets) { - window.keychain_wallets.addEmbeddedWallet( - address.toLowerCase(), - new TurnkeyWallet( - authIframeClientRef.current!, - address, - subOrganizationId, - ), - ); - } else { - throw new Error("No keychain_wallets found"); - } - - if (signerPromiseRef.current) { - signerPromiseRef.current.resolve({ - signer: { - eip191: { - address, - }, - }, - }); - resetState(); - } else { - console.error("No signer promise"); - } - }, [resolveForAccountChange, signupOrLogin, getIdTokenClaims, resetState]); - - useEffect(() => { - (async () => { - if ( - !isAuthenticated || - !user || - !authIframeClientRef.current?.iframePublicKey || - error || - signupOrLogin === undefined || - isLoading - ) { + const signup = useCallback( + async (signupOrLogin: { username: string } | { address: string }) => { + const { success, account, error } = + (await window.keychain_wallets?.turnkeyWallet.connect( + signupOrLogin, + )) as ExternalWalletResponse; + if (error?.includes("Account mismatch")) { + setChangeWallet?.(true); return; } - - try { - if ((signupOrLogin as { username: string }).username) { - await internalSignup(); - } else { - await internalLogin(); - } - } catch (error) { - signerPromiseRef.current?.reject(error as Error); - resetState(); + if (!success) { + throw new Error("Failed to connect to Turnkey: " + error); } - })(); - }, [ - isAuthenticated, - user, - signupOrLogin, - error, - internalSignup, - internalLogin, - resetState, - ]); - - const pollIframePublicKey = async ( - onSuccess: (key: string) => void, - onFailure: (err: Error) => Promise, - ) => { - const pollTimeMs = 10000; - const intervalMs = 200; - let elapsedTime = 0; - - const iFramePublicKey = - await authIframeClientRef.current?.getEmbeddedPublicKey(); - if (iFramePublicKey) { - onSuccess(iFramePublicKey); - return; - } - - const intervalId = setInterval(async () => { - const iFramePublicKey = - await authIframeClientRef.current?.getEmbeddedPublicKey(); - if (iFramePublicKey) { - clearInterval(intervalId); - onSuccess(iFramePublicKey); - } else { - console.debug("waiting for iframe public key"); - elapsedTime += intervalMs; - if (elapsedTime >= pollTimeMs) { - clearInterval(intervalId); - onFailure( - new Error("Timeout waiting for Turnkey iframe public key."), - ); - } + if (!account) { + throw new Error("No account found"); } - }, intervalMs); - }; - - const signup = useCallback( - async (signupOrLogin: { username: string } | { address: string }) => { - return new Promise((resolve, reject) => { - pollIframePublicKey( - async (iframePublicKey) => { - signerPromiseRef.current = { resolve, reject }; - - try { - const nonce = getNonce(iframePublicKey); - const popup = openPopup(""); - await loginWithPopup( - { - authorizationParams: { - connection: SOCIAL_PROVIDER_NAME, - redirect_uri: import.meta.env.VITE_ORIGIN, - nonce, - display: "touch", - tknonce: nonce, - }, - }, - { popup }, - ); + window.keychain_wallets?.addEmbeddedWallet( + account, + window.keychain_wallets?.turnkeyWallet as WalletAdapter, + ); - setSignupOrLogin(signupOrLogin); - } catch (error) { - reject(error); - resetState(); - } - }, - async (error) => { - if (authIframeClientRef.current) { - await resetIframePublicKey(authIframeClientRef.current); - } - reject(error); - resetState(); - }, - ); - }); + return { address: account, signer: { eip191: { address: account } } }; }, - [loginWithPopup, setSignupOrLogin, resetState, logout, isAuthenticated], + [setChangeWallet], ); return { signup, login: signup }; }; - -const getNonce = (seed: string) => { - return bytesToHex(sha256(seed)); -}; - -const openPopup = (url: string) => { - return window.open( - url, - "auth0:authorize:popup", - `resizable,scrollbars=no,status=1`, - ); -}; - -const resetIframePublicKey = async (authIframeClient: TurnkeyIframeClient) => { - await authIframeClient.clearEmbeddedKey(); - await authIframeClient.initEmbeddedKey(); -}; - -export const getIframePublicKey = async ( - authIframeClient: TurnkeyIframeClient, -) => { - const iframePublicKey = await authIframeClient.getEmbeddedPublicKey(); - if (!iframePublicKey) { - await resetIframePublicKey(authIframeClient); - throw new Error("No iframe public key, please try again"); - } - return iframePublicKey; -}; diff --git a/packages/keychain/src/components/connect/create/social/turnkey.ts b/packages/keychain/src/components/connect/create/social/turnkey.ts deleted file mode 100644 index 473d33210c..0000000000 --- a/packages/keychain/src/components/connect/create/social/turnkey.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { TurnkeyIframeClient } from "@turnkey/sdk-browser"; - -export const getWallet = async ( - subOrgId: string, - authIframeClient: TurnkeyIframeClient, -) => { - const wallets = await authIframeClient.getWallets({ - organizationId: subOrgId, - }); - if (wallets.wallets.length > 1) { - throw new Error( - "Multiple wallets found" + JSON.stringify(wallets, null, 2), - ); - } - if (wallets.wallets.length === 0) { - throw new Error("No wallets found"); - } - - const wallet = await authIframeClient.getWalletAccount({ - organizationId: subOrgId, - walletId: wallets.wallets[0].walletId, - }); - - return refineNonNull(wallet.account.address); -}; - -export const getOrCreateWallet = async ( - subOrgId: string, - userName: string, - authIframeClient: TurnkeyIframeClient, -): Promise => { - const wallets = await authIframeClient.getWallets({ - organizationId: subOrgId, - }); - if (wallets.wallets.length > 1 && !import.meta.env.DEV) { - throw new Error( - "Multiple wallets found" + JSON.stringify(wallets, null, 2), - ); - } - - if (wallets.wallets.length === 1) { - const wallet = await authIframeClient.getWalletAccount({ - organizationId: subOrgId, - walletId: wallets.wallets[0].walletId, - }); - return refineNonNull(wallet.account.address); - } - - const createWalletResponse = await authIframeClient.createWallet({ - organizationId: subOrgId, - walletName: userName, - accounts: [WALLET_CONFIG], - }); - - const address = refineNonNull(createWalletResponse.addresses[0]); - return address; -}; - -function refineNonNull( - input: T | null | undefined, - errorMessage?: string, -): T { - if (input == null) { - throw new Error(errorMessage ?? `Unexpected ${JSON.stringify(input)}`); - } - - return input; -} - -const WALLET_CONFIG = { - curve: "CURVE_SECP256K1" as const, - pathFormat: "PATH_FORMAT_BIP32" as const, - path: "m/44'/60'/0'/0/0" as const, - addressFormat: "ADDRESS_FORMAT_ETHEREUM" as const, -}; diff --git a/packages/keychain/src/hooks/connection.ts b/packages/keychain/src/hooks/connection.ts index 92fc9a528d..e62bbff1d6 100644 --- a/packages/keychain/src/hooks/connection.ts +++ b/packages/keychain/src/hooks/connection.ts @@ -1,3 +1,4 @@ +import { fetchController } from "@/components/connect/create/utils"; import { ConnectionContext, ConnectionContextValue, @@ -16,6 +17,7 @@ import { ResponseCodes, toArray, toSessionPolicies, + WalletAdapter, WalletBridge, } from "@cartridge/controller"; import { AsyncMethodReturns } from "@cartridge/penpal"; @@ -27,6 +29,7 @@ import { } from "@cartridge/presets"; import { useThemeEffect } from "@cartridge/ui"; import { isIframe, normalizeOrigin } from "@cartridge/ui/utils"; +import { getAddress } from "ethers"; import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { RpcProvider, shortString } from "starknet"; import { ParsedSessionPolicies, parseSessionPolicies } from "./session"; @@ -189,6 +192,80 @@ export function useConnectionValue() { } }, [rpcUrl]); + useEffect(() => { + if (!controller?.username() || !chainId) return; + + (async () => { + try { + const controllerResponse = await fetchController( + chainId, + controller.username(), + new AbortController().signal, + ); + + if ( + !controllerResponse.controller || + !controllerResponse.controller.signers || + controllerResponse.controller.signers.some( + (signer) => + signer.metadata.__typename !== "Eip191Credentials" || + signer.metadata.eip191?.[0]?.provider !== "discord", + ) + ) { + return; + } + + // At this point we know that all the signers are Eip191Credentials and that the provider is discord + // So we need to check if at least one of the signers here has an attached embedded wallet in the keychain_wallets + const hasEmbeddedWallet = controllerResponse.controller?.signers?.some( + (signer) => { + const ethAddress = ( + signer.metadata as { eip191?: Array<{ ethAddress: string }> } + ).eip191?.[0]?.ethAddress; + if (!ethAddress) return false; + + try { + return ( + window.keychain_wallets?.getEmbeddedWallet(ethAddress) !== + undefined + ); + } catch (error) { + console.error("Invalid eth address:", ethAddress, error); + return false; + } + }, + ); + + if (hasEmbeddedWallet) { + return; + } + + const signer = controllerResponse.controller?.signers?.[0]; + const ethAddress = ( + signer?.metadata as { eip191?: Array<{ ethAddress: string }> } + ).eip191?.[0]?.ethAddress; + if (!ethAddress) { + throw new Error("No eth address found"); + } + + const turnkeyWallet = window.keychain_wallets?.turnkeyWallet; + if (!turnkeyWallet) { + throw new Error("Embedded Turnkey wallet not found"); + } + + turnkeyWallet.account = getAddress(ethAddress); + turnkeyWallet.subOrganizationId = undefined; + + window.keychain_wallets?.addEmbeddedWallet( + ethAddress, + turnkeyWallet as WalletAdapter, + ); + } catch (error) { + console.error("Failed to add embedded wallet:", error); + } + })(); + }, [controller?.username, chainId]); + // Handle controller initialization useEffect(() => { // if we're not embedded (eg Slot auth/session) load controller from store and set origin/rpcUrl diff --git a/packages/keychain/src/hooks/wallets.tsx b/packages/keychain/src/hooks/wallets.tsx index f066d2bfdc..f0c118729a 100644 --- a/packages/keychain/src/hooks/wallets.tsx +++ b/packages/keychain/src/hooks/wallets.tsx @@ -4,6 +4,7 @@ import { ExternalWalletType, WalletAdapter, } from "@cartridge/controller"; +import { getAddress } from "ethers/address"; import React, { createContext, PropsWithChildren, @@ -13,6 +14,7 @@ import React, { useMemo, useState, } from "react"; +import { TurnkeyWallet } from "../wallets/social/turnkey"; import { ParentMethods, useConnection } from "./connection"; interface WalletsContextValue { @@ -184,11 +186,13 @@ export const useWallets = (): WalletsContextValue => { export class KeychainWallets { private parent: ParentMethods; private embeddedWalletsByAddress: Map = new Map(); + turnkeyWallet: TurnkeyWallet; // Method to set the parent connection once established constructor(parent: ParentMethods) { - console.log("KeychainWallets: Parent connection set."); this.parent = parent; + + this.turnkeyWallet = new TurnkeyWallet(); } /** @@ -197,7 +201,7 @@ export class KeychainWallets { * @param wallet - The wallet adapter instance. */ addEmbeddedWallet(address: string, wallet: WalletAdapter) { - this.embeddedWalletsByAddress.set(address.toLowerCase(), wallet); + this.embeddedWalletsByAddress.set(getAddress(address), wallet); } /** @@ -206,7 +210,7 @@ export class KeychainWallets { * @returns The wallet adapter instance or undefined if not found. */ getEmbeddedWallet(address: string): WalletAdapter | undefined { - return this.embeddedWalletsByAddress.get(address.toLowerCase()); + return this.embeddedWalletsByAddress.get(getAddress(address)); } /** @@ -221,7 +225,6 @@ export class KeychainWallets { message: string, ): Promise { // --- Decision Logic --- - // TODO: Implement logic to check if 'identifier' belongs to an embedded wallet (e.g., Turnkey) const embeddedWallet = this.getEmbeddedWallet(identifier); if (embeddedWallet) { diff --git a/packages/keychain/src/wallets/social/turnkey.ts b/packages/keychain/src/wallets/social/turnkey.ts new file mode 100644 index 0000000000..4a4493ede8 --- /dev/null +++ b/packages/keychain/src/wallets/social/turnkey.ts @@ -0,0 +1,404 @@ +import { Auth0Client, createAuth0Client } from "@auth0/auth0-spa-js"; +import { + ExternalPlatform, + ExternalWallet, + ExternalWalletResponse, + ExternalWalletType, +} from "@cartridge/controller"; +import { sha256 } from "@noble/hashes/sha2"; +import { bytesToHex } from "@noble/hashes/utils"; +import { Turnkey, TurnkeyIframeClient } from "@turnkey/sdk-browser"; +import { ethers, getAddress, getBytes, Signature } from "ethers"; +import { + authenticateToTurnkey, + getAuth0OidcToken, + getOrCreateTurnkeySuborg, + getOrCreateWallet, + getTurnkeySuborg, + getWallet, +} from "./turnkey_utils"; + +const SOCIAL_PROVIDER_NAME = "discord"; + +export class TurnkeyWallet { + readonly type: ExternalWalletType = "turnkey" as ExternalWalletType; + readonly platform: ExternalPlatform = "ethereum"; + account: string | undefined = undefined; + subOrganizationId: string | undefined = undefined; + private auth0ClientPromise: Promise | undefined = undefined; + private turnkeyIframePromise: Promise | undefined = + undefined; + + constructor() { + this.auth0ClientPromise = createAuth0Client({ + domain: import.meta.env.VITE_AUTH0_DOMAIN, + clientId: import.meta.env.VITE_AUTH0_CLIENT_ID, + }); + + const turnkeyIframe = document.getElementById("turnkey-iframe-container"); + if (turnkeyIframe) { + document.body.removeChild(turnkeyIframe); + } + const turnkeySdk = new Turnkey({ + apiBaseUrl: import.meta.env.VITE_TURNKEY_BASE_URL, + defaultOrganizationId: import.meta.env.VITE_TURNKEY_ORGANIZATION_ID, + }); + const iframeContainer = document.createElement("div"); + iframeContainer.style.display = "none"; + iframeContainer.id = "turnkey-iframe-container"; + document.body.appendChild(iframeContainer); + + this.turnkeyIframePromise = turnkeySdk + .iframeClient({ + iframeContainer: iframeContainer, + iframeUrl: import.meta.env.VITE_TURNKEY_IFRAME_URL, + }) + .then(async (turnkeyIframeClient: TurnkeyIframeClient) => { + await turnkeyIframeClient.initEmbeddedKey(); + return turnkeyIframeClient; + }); + } + + isAvailable(): boolean { + const turnkeyIframeClient = this.getTurnkeyIframeClient(10_000); + const auth0Client = this.getAuth0Client(10_000); + return ( + typeof window !== "undefined" && + turnkeyIframeClient !== undefined && + auth0Client !== undefined + ); + } + + getInfo(): ExternalWallet { + const available = this.isAvailable(); + + return { + type: this.type, + available, + name: "Turnkey", + platform: this.platform, + }; + } + + async connect( + signupOrLogin?: { username: string } | { address: string }, + ): Promise { + if (!signupOrLogin) { + throw new Error("No signupOrLogin"); + } + + try { + const turnkeyIframeClient = await this.getTurnkeyIframeClient(10_000); + + const iframePublicKey = await this.pollIframePublicKey(10_000); + + const nonce = getNonce(iframePublicKey); + + const auth0Client = await this.getAuth0Client(10_000); + const popup = await openPopup(""); + await auth0Client.loginWithPopup( + { + authorizationParams: { + connection: SOCIAL_PROVIDER_NAME, + redirect_uri: import.meta.env.VITE_ORIGIN, + nonce, + display: "touch", + tknonce: nonce, + }, + }, + { popup }, + ); + + const iFramePublicKey = await getIframePublicKey(turnkeyIframeClient!); + + const iFrameNonce = getNonce(iFramePublicKey); + const tokenClaims = await auth0Client.getIdTokenClaims(); + const oidcTokenString = await getAuth0OidcToken(tokenClaims, iFrameNonce); + if (!oidcTokenString) { + throw new Error("No oidcTokenString"); + } + + const subOrganizationId = + "username" in signupOrLogin + ? await getOrCreateTurnkeySuborg( + oidcTokenString, + signupOrLogin.username, + ) + : await getTurnkeySuborg(oidcTokenString); + + if (!subOrganizationId) { + throw new Error("No subOrganizationId"); + } + await authenticateToTurnkey( + subOrganizationId, + oidcTokenString, + turnkeyIframeClient!, + ); + + const turnkeyAddress = + "address" in signupOrLogin + ? await getWallet(subOrganizationId, turnkeyIframeClient!) + : await getOrCreateWallet( + subOrganizationId, + signupOrLogin.username, + turnkeyIframeClient!, + ); + if ( + "address" in signupOrLogin && + BigInt(signupOrLogin.address) !== BigInt(turnkeyAddress) + ) { + throw new Error("Account mismatch"); + } + + const checksummedAddress = getAddress(turnkeyAddress); + this.account = checksummedAddress; + this.subOrganizationId = subOrganizationId; + + return { + success: true, + wallet: this.type, + account: checksummedAddress, + }; + } catch (error) { + return { + success: false, + wallet: this.type, + error: (error as Error).message || "Unknown error", + }; + } + } + + getConnectedAccounts(): string[] { + return this.account ? [this.account] : []; + } + + async signTransaction( + transaction: string, + ): Promise> { + try { + if (!this.isAvailable() || !this.account) { + throw new Error("Turnkey is not connected"); + } + + const turnkeyIframeClient = await this.getTurnkeyIframeClient(10_000); + + const result = ( + await turnkeyIframeClient.signTransaction({ + organizationId: this.subOrganizationId, + signWith: this.account, + unsignedTransaction: transaction, + type: "TRANSACTION_TYPE_ETHEREUM", + }) + ).signedTransaction; + + return { + success: true, + wallet: this.type, + result: result, + }; + } catch (error) { + console.error(`Error signing transaction with Turnkey:`, error); + return { + success: false, + wallet: this.type, + error: (error as Error).message || "Unknown error", + }; + } + } + + async signMessage(message: string): Promise> { + try { + if (!this.isAvailable() || !this.account) { + throw new Error("Turnkey is not connected"); + } + + if (!this.subOrganizationId) { + const { success, error } = await this.connect({ + address: this.account, + }); + if (!success) { + throw new Error(error); + } + } + + const paddedMessage = `0x${message.replace("0x", "").padStart(64, "0")}`; + const messageBytes = getBytes(paddedMessage); + const messageHash = ethers.hashMessage(messageBytes); + + const turnkeyIframeClient = await this.getTurnkeyIframeClient(10_000); + + const { r, s, v } = await turnkeyIframeClient.signRawPayload({ + organizationId: this.subOrganizationId, + signWith: this.account, + payload: messageHash, + encoding: "PAYLOAD_ENCODING_HEXADECIMAL", + hashFunction: "HASH_FUNCTION_NO_OP", + }); + + const rHex = r.startsWith("0x") ? r : "0x" + r; + const sHex = s.startsWith("0x") ? s : "0x" + s; + + const vNumber = parseInt(v, 16); + + if (isNaN(vNumber)) { + console.error(`Invalid recovery ID (v) received from Turnkey: ${v}`); + throw new Error(`Invalid recovery ID (v) received: ${v}`); + } + + const signature = Signature.from({ + r: rHex, + s: sHex, + v: vNumber, + }); + + return { + success: true, + wallet: this.type, + result: signature.serialized, + account: this.account, + }; + } catch (error) { + console.error(`Error signing message with Turnkey:`, error); + return { + success: false, + wallet: this.type, + error: (error as Error).message || "Unknown error", + }; + } + } + + async signTypedData(data: string): Promise> { + return this.signMessage(data); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async sendTransaction(_txn: string): Promise { + return { + success: false, + wallet: this.type, + error: "Not implemented", + }; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async switchChain(_chainId: string): Promise { + return false; + } + + async getBalance( + tokenAddress?: string, + ): Promise> { + try { + if (!this.isAvailable() || !this.account) { + throw new Error("Turnkey is not connected"); + } + + if (tokenAddress) { + return { + success: false, + wallet: this.type, + error: "Not implemented for ERC20", + }; + } else { + return { success: true, wallet: this.type, result: "0" }; + } + } catch (error) { + console.error(`Error getting balance from Turnkey:`, error); + return { + success: false, + wallet: this.type, + error: (error as Error).message || "Unknown error", + }; + } + } + + private pollIframePublicKey = async (pollTimeMs: number): Promise => { + const intervalMs = 200; + let elapsedTime = 0; + + const turnkeyIframeClient = await this.getTurnkeyIframeClient(10_000); + const iFramePublicKey = await turnkeyIframeClient.getEmbeddedPublicKey(); + if (iFramePublicKey) { + return iFramePublicKey; + } + + return new Promise((resolve, reject) => { + const intervalId = setInterval(async () => { + const iFramePublicKey = + await turnkeyIframeClient.getEmbeddedPublicKey(); + if (iFramePublicKey) { + clearInterval(intervalId); + resolve(iFramePublicKey); + } else { + elapsedTime += intervalMs; + if (elapsedTime >= pollTimeMs) { + clearInterval(intervalId); + reject(new Error("Timeout waiting for Turnkey iframe public key.")); + } + } + }, intervalMs); + }); + }; + + private async getTurnkeyIframeClient( + timeoutMs: number, + ): Promise { + if (!this.turnkeyIframePromise) { + throw new Error("Turnkey iframe client not initialized"); + } + return this.getPromiseResult(this.turnkeyIframePromise, timeoutMs); + } + + private async getAuth0Client(timeoutMs: number): Promise { + if (!this.auth0ClientPromise) { + throw new Error("Auth0 client not initialized"); + } + return this.getPromiseResult(this.auth0ClientPromise, timeoutMs); + } + + private async getPromiseResult( + promise: Promise, + timeoutMs: number, + ): Promise { + const timeoutId = setTimeout(() => { + throw new Error("Timeout waiting for promise"); + }, timeoutMs); + + const result = await promise; + clearTimeout(timeoutId); + + return result; + } +} + +const resetIframePublicKey = async (authIframeClient: TurnkeyIframeClient) => { + await authIframeClient.clearEmbeddedKey(); + await authIframeClient.initEmbeddedKey(); +}; + +export const getIframePublicKey = async ( + authIframeClient: TurnkeyIframeClient, +) => { + const iframePublicKey = await authIframeClient.getEmbeddedPublicKey(); + if (!iframePublicKey) { + await resetIframePublicKey(authIframeClient); + throw new Error("No iframe public key, please try again"); + } + return iframePublicKey; +}; + +const openPopup = (url: string) => { + const popup = window.open( + url, + "auth0:authorize:popup", + `resizable,scrollbars=no,status=1`, + ); + if (!popup || popup.closed) { + throw new Error("Failed to open authentication popup - may be blocked"); + } + return popup; +}; + +const getNonce = (seed: string) => { + return bytesToHex(sha256(seed)); +}; diff --git a/packages/keychain/src/wallets/social/turnkey_utils.ts b/packages/keychain/src/wallets/social/turnkey_utils.ts new file mode 100644 index 0000000000..f42dde8e39 --- /dev/null +++ b/packages/keychain/src/wallets/social/turnkey_utils.ts @@ -0,0 +1,213 @@ +import { IdToken } from "@auth0/auth0-react"; +import { fetchApiCreator } from "@cartridge/ui/utils"; +import { TurnkeyIframeClient } from "@turnkey/sdk-browser"; +import { jwtDecode, JwtPayload } from "jwt-decode"; +import { getIframePublicKey } from "./turnkey"; + +export const getWallet = async ( + subOrgId: string, + authIframeClient: TurnkeyIframeClient, +) => { + const wallets = await authIframeClient.getWallets({ + organizationId: subOrgId, + }); + if (wallets.wallets.length > 1) { + throw new Error( + "Multiple wallets found" + JSON.stringify(wallets, null, 2), + ); + } + if (wallets.wallets.length === 0) { + throw new Error("No wallets found"); + } + + const wallet = await authIframeClient.getWalletAccount({ + organizationId: subOrgId, + walletId: wallets.wallets[0].walletId, + }); + + return refineNonNull(wallet.account.address); +}; + +export const getOrCreateWallet = async ( + subOrgId: string, + userName: string, + authIframeClient: TurnkeyIframeClient, +): Promise => { + const wallets = await authIframeClient.getWallets({ + organizationId: subOrgId, + }); + if (wallets.wallets.length > 1 && !import.meta.env.DEV) { + throw new Error( + "Multiple wallets found" + JSON.stringify(wallets, null, 2), + ); + } + + if (wallets.wallets.length === 1) { + const wallet = await authIframeClient.getWalletAccount({ + organizationId: subOrgId, + walletId: wallets.wallets[0].walletId, + }); + return refineNonNull(wallet.account.address); + } + + const createWalletResponse = await authIframeClient.createWallet({ + organizationId: subOrgId, + walletName: userName, + accounts: [WALLET_CONFIG], + }); + + const address = refineNonNull(createWalletResponse.addresses[0]); + return address; +}; + +function refineNonNull( + input: T | null | undefined, + errorMessage?: string, +): T { + if (input == null) { + throw new Error(errorMessage ?? `Unexpected ${JSON.stringify(input)}`); + } + + return input; +} + +const WALLET_CONFIG = { + curve: "CURVE_SECP256K1" as const, + pathFormat: "PATH_FORMAT_BIP32" as const, + path: "m/44'/60'/0'/0/0" as const, + addressFormat: "ADDRESS_FORMAT_ETHEREUM" as const, +}; + +export const getAuth0OidcToken = async ( + tokenClaims: IdToken | undefined, + expectedNonce: string, +) => { + if (!tokenClaims) { + throw new Error("Not authenticated with Auth0 yet"); + } + + const oidcTokenString = tokenClaims.__raw; + if (!oidcTokenString) { + throw new Error("Raw ID token string (__raw) not found in claims"); + } + + const decodedToken = jwtDecode(oidcTokenString); + if (!decodedToken.tknonce) { + return undefined; + } + + if (decodedToken.tknonce !== expectedNonce) { + throw new Error( + `Nonce mismatch: expected ${expectedNonce}, got ${decodedToken.tknonce}`, + ); + } + return tokenClaims.__raw; +}; + +interface DecodedIdToken extends JwtPayload { + nonce?: string; + tknonce?: string; +} + +export const SOCIAL_PROVIDER_NAME = "discord"; + +export const fetchApi = fetchApiCreator( + `${import.meta.env.VITE_CARTRIDGE_API_URL}/oauth2`, + { + credentials: "same-origin", + }, +); + +export const getTurnkeySuborg = async ( + oidcToken: string, +): Promise => { + const getSuborgsResponse = await fetchApi("suborgs", { + filterType: "OIDC_TOKEN", + filterValue: oidcToken, + }); + if (!getSuborgsResponse) { + throw new Error("No suborgs response found"); + } + + if (getSuborgsResponse.organizationIds.length > 1) { + throw new Error("Multiple suborgs found"); + } + + return getSuborgsResponse.organizationIds[0]; +}; + +export const getOrCreateTurnkeySuborg = async ( + oidcToken: string, + username: string, +) => { + const getSuborgsResponse = await fetchApi("suborgs", { + filterType: "OIDC_TOKEN", + filterValue: oidcToken, + }); + if (!getSuborgsResponse) { + throw new Error("No suborgs response found"); + } + + let targetSubOrgId: string; + if (getSuborgsResponse.organizationIds.length === 0) { + const createSuborgResponse = await fetchApi( + "create-suborg", + { + rootUserUsername: username, + oauthProviders: [{ providerName: SOCIAL_PROVIDER_NAME, oidcToken }], + }, + ); + targetSubOrgId = createSuborgResponse.subOrganizationId; + } else if (getSuborgsResponse.organizationIds.length === 1) { + targetSubOrgId = getSuborgsResponse.organizationIds[0]; + } else { + if (import.meta.env.DEV) { + targetSubOrgId = getSuborgsResponse.organizationIds[0]; + } else { + // We don't want to handle multiple suborgs per user at the moment + throw new Error("Multiple suborgs found for user"); + } + } + + return targetSubOrgId; +}; + +export const authenticateToTurnkey = async ( + subOrgId: string, + oidcToken: string, + authIframeClient: TurnkeyIframeClient, +) => { + const iframePublicKey = await getIframePublicKey(authIframeClient); + + const authResponse = await fetchApi( + `auth`, + { + suborgID: subOrgId, + targetPublicKey: iframePublicKey, + oidcToken, + invalidateExisting: true, + }, + { + client_id: "turnkey", + }, + ); + + const injectResponse = await authIframeClient.injectCredentialBundle( + authResponse.credentialBundle, + ); + if (!injectResponse) { + throw new Error("Failed to inject credentials into Turnkey"); + } +}; + +type GetSuborgsResponse = { + organizationIds: string[]; +}; + +type CreateSuborgResponse = { + subOrganizationId: string; +}; + +type AuthResponse = { + credentialBundle: string; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6eda035791..6f3c019b3f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -498,6 +498,9 @@ importers: '@auth0/auth0-react': specifier: ^2.3.0 version: 2.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@auth0/auth0-spa-js': + specifier: ^2.1.3 + version: 2.1.3 '@cartridge/controller': specifier: workspace:* version: link:../controller @@ -535,7 +538,7 @@ importers: specifier: ^4.8.0 version: 4.10.0 '@turnkey/sdk-browser': - specifier: ^4.0.0 + specifier: ^4.1.0 version: 4.1.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.24.4) '@turnkey/sdk-react': specifier: ^4.2.1