diff --git a/examples/with-federated-passkeys/.env.local.example b/examples/with-federated-passkeys/.env.local.example index 9a6d95474..56519191a 100644 --- a/examples/with-federated-passkeys/.env.local.example +++ b/examples/with-federated-passkeys/.env.local.example @@ -1,4 +1,5 @@ API_PUBLIC_KEY="" API_PRIVATE_KEY="" NEXT_PUBLIC_ORGANIZATION_ID="" -NEXT_PUBLIC_BASE_URL="https://api.turnkey.com" \ No newline at end of file +NEXT_PUBLIC_BASE_URL="https://api.turnkey.com" +NEXT_PUBLIC_RPID="localhost" diff --git a/examples/with-federated-passkeys/README.md b/examples/with-federated-passkeys/README.md index d90d81a1c..dbc6fef87 100644 --- a/examples/with-federated-passkeys/README.md +++ b/examples/with-federated-passkeys/README.md @@ -36,6 +36,7 @@ Now open `.env.local` and add the missing environment variables: - `API_PRIVATE_KEY` - `NEXT_PUBLIC_ORGANIZATION_ID` - `NEXT_PUBLIC_BASE_URL` (the `NEXT_PUBLIC` prefix makes the env variable accessible to the frontend app) +- `NEXT_PUBLIC_RPID` should be `localhost` unless you're accessing this demo through your own domain ### 3/ Running the app @@ -44,3 +45,12 @@ $ pnpm run dev ``` This command will run a NextJS app on port 3000. If you navigate to http://localhost:3000 in your browser, you can follow the prompts to create a sub-organization and private key for the newly created sub-organization. + +### Testing passkey prompts on real mobile devices + +The easiest way to test this demo on mobile is through ngrok: + +- Install by following the instruction here: https://dashboard.ngrok.com/get-started/setup +- Open a new tunnel to port 3000: `ngrok http 3000` +- Update `NEXT_PUBLIC_RPID` to the ngrok domain (e.g. `372b-68-203-12-187.ngrok-free.app`) +- Now visit the ngrok URL on your mobile device diff --git a/examples/with-federated-passkeys/src/app/globals.css b/examples/with-federated-passkeys/src/app/globals.css deleted file mode 100644 index f4bd77c0c..000000000 --- a/examples/with-federated-passkeys/src/app/globals.css +++ /dev/null @@ -1,107 +0,0 @@ -:root { - --max-width: 1100px; - --border-radius: 12px; - --font-mono: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", - "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro", - "Fira Mono", "Droid Sans Mono", "Courier New", monospace; - - --foreground-rgb: 0, 0, 0; - --background-start-rgb: 214, 219, 220; - --background-end-rgb: 255, 255, 255; - - --primary-glow: conic-gradient( - from 180deg at 50% 50%, - #16abff33 0deg, - #0885ff33 55deg, - #54d6ff33 120deg, - #0071ff33 160deg, - transparent 360deg - ); - --secondary-glow: radial-gradient( - rgba(255, 255, 255, 1), - rgba(255, 255, 255, 0) - ); - - --tile-start-rgb: 239, 245, 249; - --tile-end-rgb: 228, 232, 233; - --tile-border: conic-gradient( - #00000080, - #00000040, - #00000030, - #00000020, - #00000010, - #00000010, - #00000080 - ); - - --callout-rgb: 238, 240, 241; - --callout-border-rgb: 172, 175, 176; - --card-rgb: 180, 185, 188; - --card-border-rgb: 131, 134, 135; -} - -@media (prefers-color-scheme: dark) { - :root { - --foreground-rgb: 255, 255, 255; - --background-start-rgb: 0, 0, 0; - --background-end-rgb: 0, 0, 0; - - --primary-glow: radial-gradient(rgba(1, 65, 255, 0.4), rgba(1, 65, 255, 0)); - --secondary-glow: linear-gradient( - to bottom right, - rgba(1, 65, 255, 0), - rgba(1, 65, 255, 0), - rgba(1, 65, 255, 0.3) - ); - - --tile-start-rgb: 2, 13, 46; - --tile-end-rgb: 2, 5, 19; - --tile-border: conic-gradient( - #ffffff80, - #ffffff40, - #ffffff30, - #ffffff20, - #ffffff10, - #ffffff10, - #ffffff80 - ); - - --callout-rgb: 20, 20, 20; - --callout-border-rgb: 108, 108, 108; - --card-rgb: 100, 100, 100; - --card-border-rgb: 200, 200, 200; - } -} - -* { - box-sizing: border-box; - padding: 0; - margin: 0; -} - -html, -body { - max-width: 100vw; - overflow-x: hidden; -} - -body { - color: rgb(var(--foreground-rgb)); - background: linear-gradient( - to bottom, - transparent, - rgb(var(--background-end-rgb)) - ) - rgb(var(--background-start-rgb)); -} - -a { - color: inherit; - text-decoration: none; -} - -@media (prefers-color-scheme: dark) { - html { - color-scheme: dark; - } -} diff --git a/examples/with-federated-passkeys/src/app/types.ts b/examples/with-federated-passkeys/src/app/types.ts new file mode 100644 index 000000000..05f3638db --- /dev/null +++ b/examples/with-federated-passkeys/src/app/types.ts @@ -0,0 +1,19 @@ +export type CreateSubOrgResponse = { + subOrgId: string; + wallet: TFormattedWallet; +}; + +export type GetWalletRequest = { + organizationId: string; +}; + +export type TFormattedWallet = { + id: string; + name: string; + accounts: TFormattedWalletAccount[]; +}; + +export type TFormattedWalletAccount = { + address: string; + path: string; +}; diff --git a/examples/with-federated-passkeys/src/app/util.ts b/examples/with-federated-passkeys/src/app/util.ts index d26b4fd42..f21c06e36 100644 --- a/examples/with-federated-passkeys/src/app/util.ts +++ b/examples/with-federated-passkeys/src/app/util.ts @@ -1,3 +1,5 @@ +import { TFormattedWallet } from "./types"; + export function refineNonNull( input: T | null | undefined, errorMessage?: string @@ -8,3 +10,18 @@ export function refineNonNull( return input; } + +/** + * This function returns the next available BIP 32 path for the wallet + * For example: a wallet with the last address at "m/44'/60'/0'/0/13" will yield "m/44'/60'/0'/0/14" + * @param wallet + */ +export function getNextPath(wallet: TFormattedWallet): string { + const lastAccount = wallet.accounts[wallet.accounts.length - 1]; + const lastAccountNum = parseInt(lastAccount.path.split("/")[5]); + return lastAccount.path + .split("/") + .slice(0, 5) + .concat((lastAccountNum + 1).toString()) + .join("/"); +} diff --git a/examples/with-federated-passkeys/src/pages/api/createSubOrg.ts b/examples/with-federated-passkeys/src/pages/api/createSubOrg.ts index a4098cdab..45abd73cf 100644 --- a/examples/with-federated-passkeys/src/pages/api/createSubOrg.ts +++ b/examples/with-federated-passkeys/src/pages/api/createSubOrg.ts @@ -5,6 +5,7 @@ import { createActivityPoller, } from "@turnkey/http"; import { ApiKeyStamper } from "@turnkey/api-key-stamper"; +import { CreateSubOrgResponse, TFormattedWallet } from "@/app/types"; type TAttestation = TurnkeyApiTypes["v1Attestation"]; @@ -14,16 +15,18 @@ type CreateSubOrgRequest = { attestation: TAttestation; }; -type CreateSubOrgResponse = { - subOrgId: string; - privateKeyId: string; - privateKeyAddress: string; -}; - type ErrorMessage = { message: string; }; +// 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 +const ETHEREUM_WALLET_DEFAULT_PATH = "m/44'/60'/0'/0/0"; + export default async function createUser( req: NextApiRequest, res: NextApiResponse @@ -72,7 +75,7 @@ export default async function createUser( { curve: "CURVE_SECP256K1", pathFormat: "PATH_FORMAT_BIP32", - path: "m/44'/60'/0'/0/0", + path: ETHEREUM_WALLET_DEFAULT_PATH, addressFormat: "ADDRESS_FORMAT_ETHEREUM", }, ], @@ -81,20 +84,25 @@ export default async function createUser( }); const subOrgId = refineNonNull( - completedActivity.result.createSubOrganizationResultV3?.subOrganizationId - ); - const privateKeys = refineNonNull( - completedActivity.result.createSubOrganizationResultV3?.privateKeys + completedActivity.result.createSubOrganizationResultV4?.subOrganizationId ); - const privateKeyId = refineNonNull(privateKeys?.[0]?.privateKeyId); - const privateKeyAddress = refineNonNull( - privateKeys?.[0]?.addresses?.[0]?.address + const wallet = refineNonNull( + completedActivity.result.createSubOrganizationResultV4?.wallet ); + const walletAddress = wallet.addresses?.[0]; res.status(200).json({ - subOrgId, - privateKeyId, - privateKeyAddress, + subOrgId: subOrgId, + wallet: { + id: wallet.walletId, + name: walletName, + accounts: [ + { + address: walletAddress, + path: ETHEREUM_WALLET_DEFAULT_PATH, + }, + ], + }, }); } catch (e) { console.error(e); diff --git a/examples/with-federated-passkeys/src/pages/api/getPrivateKeys.ts b/examples/with-federated-passkeys/src/pages/api/getPrivateKeys.ts deleted file mode 100644 index 74c37f954..000000000 --- a/examples/with-federated-passkeys/src/pages/api/getPrivateKeys.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; -import { TurnkeyApiTypes, TurnkeyClient } from "@turnkey/http"; -import { ApiKeyStamper } from "@turnkey/api-key-stamper"; - -type TPrivateKey = TurnkeyApiTypes["v1PrivateKey"]; - -type TFormattedPrivateKey = { - privateKeyId: string; - privateKeyName: string; - privateKeyAddress: string; -}; - -type GetPrivateKeysRequest = { - organizationId: string; -}; - -type GetPrivateKeysResponse = { - privateKeys: TFormattedPrivateKey[]; -}; - -type ErrorMessage = { - message: string; -}; - -// This getter _can_ be performed by the parent org -export default async function getPrivateKeys( - req: NextApiRequest, - res: NextApiResponse -) { - const getPrivateKeysRequest = req.body as GetPrivateKeysRequest; - - const turnkeyClient = new TurnkeyClient( - { baseUrl: process.env.NEXT_PUBLIC_BASE_URL! }, - new ApiKeyStamper({ - apiPublicKey: process.env.API_PUBLIC_KEY!, - apiPrivateKey: process.env.API_PRIVATE_KEY!, - }) - ); - - const organizationId = getPrivateKeysRequest.organizationId; - - try { - const privateKeysResponse = await turnkeyClient.getPrivateKeys({ - organizationId, - }); - - // By default, the API will return private keys in descending order of create date. - // We reverse it here, so that visually the most recently created private keys will be - // added to the bottom of the list instead of the top. - const privateKeys = privateKeysResponse.privateKeys - .reverse() - .map((pk: TPrivateKey) => { - return { - privateKeyId: pk.privateKeyId!, - privateKeyName: pk.privateKeyName!, - privateKeyAddress: pk.addresses[0].address!, - }; - }); - - res.status(200).json({ - privateKeys, - }); - } catch (e) { - console.error(e); - - res.status(500).json({ - message: "Something went wrong.", - }); - } - - res.json; -} diff --git a/examples/with-federated-passkeys/src/pages/api/getWallet.ts b/examples/with-federated-passkeys/src/pages/api/getWallet.ts new file mode 100644 index 000000000..5ea7e20bf --- /dev/null +++ b/examples/with-federated-passkeys/src/pages/api/getWallet.ts @@ -0,0 +1,59 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { TurnkeyApiTypes, TurnkeyClient } from "@turnkey/http"; +import { ApiKeyStamper } from "@turnkey/api-key-stamper"; +import { GetWalletRequest, TFormattedWallet } from "@/app/types"; + +type TWalletAccount = TurnkeyApiTypes["v1WalletAccount"]; + +type ErrorMessage = { + message: string; +}; + +// This can be performed by the parent org since parent orgs have read-only access to all their sub-orgs +export default async function getWallet( + req: NextApiRequest, + res: NextApiResponse +) { + const getWalletRequest = req.body as GetWalletRequest; + + const turnkeyClient = new TurnkeyClient( + { baseUrl: process.env.NEXT_PUBLIC_BASE_URL! }, + new ApiKeyStamper({ + apiPublicKey: process.env.API_PUBLIC_KEY!, + apiPrivateKey: process.env.API_PRIVATE_KEY!, + }) + ); + + const organizationId = getWalletRequest.organizationId; + + try { + const walletsResponse = await turnkeyClient.getWallets({ + organizationId, + }); + const accountsResponse = await turnkeyClient.getWalletAccounts({ + organizationId: organizationId, + walletId: walletsResponse.wallets[0].walletId, + }); + + const accounts = accountsResponse.accounts.map((acc: TWalletAccount) => { + return { + address: acc.address, + path: acc.path, + }; + }); + + res.status(200).json({ + id: walletsResponse.wallets[0].walletId, + name: walletsResponse.wallets[0].walletName, + accounts: accounts, + }); + } catch (e) { + console.error(e); + + res.status(500).json({ + message: "Something went wrong.", + }); + } + + res.json; +} diff --git a/examples/with-federated-passkeys/src/pages/index.module.css b/examples/with-federated-passkeys/src/pages/index.module.css index 50896cd13..876b2f269 100644 --- a/examples/with-federated-passkeys/src/pages/index.module.css +++ b/examples/with-federated-passkeys/src/pages/index.module.css @@ -17,12 +17,15 @@ } .main { - display: flex; - flex-direction: column; - justify-content: space-between; - align-items: center; - padding: 6rem; - gap: 60px; + max-width: 800px; + margin: auto; + font-family: "Inter"; +} + +.logo { + display: block; + margin: 24px auto; + width: 100px; } .input { @@ -42,6 +45,7 @@ .prompt { font-family: "Inter"; + text-align: center; } .base { @@ -75,23 +79,19 @@ gap: 10px; } -.baseTable { - font-family: "Inter"; - width: 75%; - display: flex; - justify-content: center; - align-items: center; -} - .table { - width: 100%; + word-break: break-all; } .td { - border-spacing: 1px; + /* From https://qwtel.com/posts/software/the-monospaced-system-ui-css-font-stack/ */ + font-family: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", + "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro", + "Fira Mono", "Droid Sans Mono", "Courier New", monospace; + padding: 10px; } .th { - padding-right: 5vw; - border-spacing: 1px; + border-bottom: 1px solid #4d4d4d; + padding: 10px; } diff --git a/examples/with-federated-passkeys/src/pages/index.tsx b/examples/with-federated-passkeys/src/pages/index.tsx index 781fff40b..3eea49dd3 100644 --- a/examples/with-federated-passkeys/src/pages/index.tsx +++ b/examples/with-federated-passkeys/src/pages/index.tsx @@ -5,19 +5,21 @@ import { WebauthnStamper } from "@turnkey/webauthn-stamper"; import { useForm } from "react-hook-form"; import axios from "axios"; import * as React from "react"; +import { CreateSubOrgResponse, TFormattedWallet } from "@/app/types"; +import { getNextPath } from "@/app/util"; type subOrgFormData = { subOrgName: string; }; -type privateKeyFormData = { - privateKeyName: string; +type walletAccountFormData = { + path: string; }; -type privateKeyResult = { - privateKeyId: string; - privateKeyName: string; - privateKeyAddress: string; +type walletResult = { + walletId: string; + walletName: string; + accounts: string; }; // All algorithms can be found here: https://www.iana.org/assignments/cose/cose.xhtml#algorithms @@ -54,18 +56,17 @@ function sleep(ms: number): Promise { export default function Home() { const [subOrgId, setSubOrgId] = React.useState(null); - const [privateKeys, setPrivateKeys] = React.useState([]); + const [wallet, setWallet] = React.useState(null); const { register: subOrgFormRegister, handleSubmit: subOrgFormSubmit } = useForm(); const { - register: privateKeyFormRegister, - handleSubmit: privateKeyFormSubmit, - } = useForm(); + register: createWalletAccountFormRegister, + handleSubmit: createWalletAccountFormSubmit, + } = useForm(); - const getPrivateKeys = async (organizationId: string) => { - const res = await axios.post("/api/getPrivateKeys", { organizationId }); - - setPrivateKeys(res.data.privateKeys); + const getWallet = async (organizationId: string) => { + const res = await axios.post("/api/getWallet", { organizationId }); + setWallet(res.data); }; const { register: _loginFormRegister, handleSubmit: loginFormSubmit } = @@ -74,35 +75,45 @@ export default function Home() { const turnkeyClient = new TurnkeyClient( { baseUrl: process.env.NEXT_PUBLIC_BASE_URL! }, new WebauthnStamper({ - rpId: "localhost", + rpId: process.env.NEXT_PUBLIC_RPID!, }) ); - const createPrivateKey = async (data: privateKeyFormData) => { - if (!subOrgId) { + const createWalletAccount = async (data: walletAccountFormData) => { + if (subOrgId === null) { throw new Error("sub-org id not found"); } + if (wallet === null) { + throw new Error("wallet not found"); + } - const signedRequest = await turnkeyClient.stampCreatePrivateKeys({ - type: "ACTIVITY_TYPE_CREATE_PRIVATE_KEYS_V2", - organizationId: subOrgId, - timestampMs: String(Date.now()), - parameters: { - privateKeys: [ - { - privateKeyName: data.privateKeyName, - curve: "CURVE_SECP256K1", - addressFormats: ["ADDRESS_FORMAT_ETHEREUM"], - privateKeyTags: [], - }, - ], - }, - }); + try { + const signedRequest = await turnkeyClient.stampCreateWalletAccounts({ + type: "ACTIVITY_TYPE_CREATE_WALLET_ACCOUNTS", + organizationId: subOrgId, + timestampMs: String(Date.now()), + parameters: { + walletId: wallet.id, + accounts: [ + { + path: data.path, + pathFormat: "PATH_FORMAT_BIP32", + curve: "CURVE_SECP256K1", + addressFormat: "ADDRESS_FORMAT_ETHEREUM", + }, + ], + }, + }); - await axios.post("/api/proxyRequest", signedRequest); - await sleep(1000); // alternative would be to poll the activity itself repeatedly - await getPrivateKeys(subOrgId); - alert(`Hooray! Key "${data.privateKeyName}" created.`); + await axios.post("/api/proxyRequest", signedRequest); + await sleep(1000); // alternative would be to poll the activity itself repeatedly + await getWallet(subOrgId); + alert(`Hooray! New address at path "${data.path}" created.`); + } catch (e: any) { + const message = `caught error: ${e.toString()}`; + console.error(message); + alert(message); + } }; const createSubOrg = async (data: subOrgFormData) => { @@ -119,7 +130,7 @@ export default function Home() { userVerification: "preferred", }, rp: { - id: "localhost", + id: process.env.NEXT_PUBLIC_RPID!, name: "Turnkey Federated Passkey Demo", }, challenge, @@ -147,44 +158,31 @@ export default function Home() { challenge: base64UrlEncode(challenge), }); - setSubOrgId(res.data.subOrgId); - setPrivateKeys([ - ...privateKeys, - { - privateKeyId: res.data.privateKeyId, - privateKeyName: res.data.privateKeyName, - privateKeyAddress: res.data.privateKeyAddress, - }, - ]); + const subOrgResponse = res.data as CreateSubOrgResponse; + + setSubOrgId(subOrgResponse.subOrgId); + setWallet(subOrgResponse.wallet); }; - const privateKeysTable = ( -
- - - - - - - {privateKeys.map((val, key) => { - return ( - - - - - ); - })} - -
NameAddress
{val.privateKeyName}{val.privateKeyAddress}
-
+ const walletTable = ( + + + + + + + {wallet?.accounts.map((account, key) => { + return ( + + + + + ); + })} + +
AddressPath
{account.address}{account.path}
); - const privateKeyElements = privateKeys.map((pk) => ( -
  • - {pk.privateKeyAddress} -
  • - )); - const login = async () => { // We use the parent org ID, which we know at all times, const res = await turnkeyClient.getWhoami({ @@ -194,13 +192,17 @@ export default function Home() { // have a DB. Note that we are able to perform this lookup by using the // credential ID from the users WebAuthn stamp. setSubOrgId(res.organizationId); - - await getPrivateKeys(res.organizationId); + await getWallet(res.organizationId); }; return (
    - + Turnkey Logo {!subOrgId && (
    -

    - First, create your sub-organization: -

    +

    Create your sub-organization:

    )} - {subOrgId && privateKeys.length === 1 && ( -
    -

    - 🚀🥳🎉 Hooray! Here's your first private key address: -

    - {privateKeyElements} -
    - )} - {subOrgId && privateKeys.length > 1 && ( -
    -

    - 🚀🥳🎉 Hooray! Here are your private keys: -

    - {privateKeysTable} -
    - )} - {subOrgId && ( -
    -

    - 👀 Want more? Create another using your passkey{" "} -

    - -
    + )}
    );