diff --git a/.changeset/popular-ravens-reflect.md b/.changeset/popular-ravens-reflect.md new file mode 100644 index 00000000000..ec69d8ac485 --- /dev/null +++ b/.changeset/popular-ravens-reflect.md @@ -0,0 +1,23 @@ +--- +"thirdweb": minor +--- + +Add support for backend wallets. + +This is useful is you have a backend that is connected to an that you want to have programmatic access to a wallet without managing private keys. + +Here's how you'd do it: + +```typescript +const wallet = inAppWallet(); +const account = await wallet.connect({ + strategy: "backend", + client: createThirdwebClient({ + secretKey: "...", + }), + walletSecret: "...", + }); +console.log("account.address", account.address); +``` + +Note that `walletSecret` should be generated by you and securely stored to uniquely authenticate to the given wallet. diff --git a/packages/thirdweb/src/wallets/in-app/core/authentication/backend.test.ts b/packages/thirdweb/src/wallets/in-app/core/authentication/backend.test.ts new file mode 100644 index 00000000000..f848ba4fc36 --- /dev/null +++ b/packages/thirdweb/src/wallets/in-app/core/authentication/backend.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from "vitest"; +import { TEST_CLIENT } from "~test/test-clients.js"; +import { getClientFetch } from "../../../../utils/fetch.js"; +import { stringify } from "../../../../utils/json.js"; +import { backendAuthenticate } from "./backend.js"; +import { getLoginUrl } from "./getLoginPath.js"; + +// Mock dependencies +vi.mock("../../../../utils/fetch.js"); +vi.mock("./getLoginPath.js"); + +describe("backendAuthenticate", () => { + it("should successfully authenticate and return token", async () => { + // Mock response data + const mockResponse = { + token: "mock-token", + cookieString: "mock-cookie", + }; + + // Mock fetch implementation + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }); + + // Mock dependencies + vi.mocked(getClientFetch).mockReturnValue(mockFetch); + vi.mocked(getLoginUrl).mockReturnValue("/auth/login"); + + const result = await backendAuthenticate({ + client: TEST_CLIENT, + walletSecret: "test-secret", + }); + + // Verify the fetch call + expect(mockFetch).toHaveBeenCalledWith("/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: stringify({ walletSecret: "test-secret" }), + }); + + // Verify response + expect(result).toEqual(mockResponse); + }); + + it("should throw error when authentication fails", async () => { + // Mock failed fetch response + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + }); + + // Mock dependencies + vi.mocked(getClientFetch).mockReturnValue(mockFetch); + vi.mocked(getLoginUrl).mockReturnValue("/auth/login"); + + // Test inputs + const args = { + client: TEST_CLIENT, + walletSecret: "test-secret", + }; + + // Verify error is thrown + await expect(backendAuthenticate(args)).rejects.toThrow( + "Failed to generate backend account", + ); + }); +}); diff --git a/packages/thirdweb/src/wallets/in-app/core/authentication/backend.ts b/packages/thirdweb/src/wallets/in-app/core/authentication/backend.ts new file mode 100644 index 00000000000..9153891caf9 --- /dev/null +++ b/packages/thirdweb/src/wallets/in-app/core/authentication/backend.ts @@ -0,0 +1,36 @@ +import type { ThirdwebClient } from "../../../../client/client.js"; +import { getClientFetch } from "../../../../utils/fetch.js"; +import { stringify } from "../../../../utils/json.js"; +import type { Ecosystem } from "../wallet/types.js"; +import { getLoginUrl } from "./getLoginPath.js"; +import type { AuthStoredTokenWithCookieReturnType } from "./types.js"; + +/** + * Authenticates via the wallet secret + * @internal + */ +export async function backendAuthenticate(args: { + client: ThirdwebClient; + walletSecret: string; + ecosystem?: Ecosystem; +}): Promise { + const clientFetch = getClientFetch(args.client, args.ecosystem); + const path = getLoginUrl({ + authOption: "backend", + client: args.client, + ecosystem: args.ecosystem, + }); + const res = await clientFetch(`${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: stringify({ + walletSecret: args.walletSecret, + }), + }); + + if (!res.ok) throw new Error("Failed to generate backend account"); + + return (await res.json()) satisfies AuthStoredTokenWithCookieReturnType; +} diff --git a/packages/thirdweb/src/wallets/in-app/core/authentication/types.ts b/packages/thirdweb/src/wallets/in-app/core/authentication/types.ts index f9c6bb5c037..8b244e8be4b 100644 --- a/packages/thirdweb/src/wallets/in-app/core/authentication/types.ts +++ b/packages/thirdweb/src/wallets/in-app/core/authentication/types.ts @@ -74,7 +74,10 @@ export type SingleStepAuthArgsType = } | { strategy: "guest"; - client: ThirdwebClient; + } + | { + strategy: "backend"; + walletSecret: string; }; export type AuthArgsType = (MultiStepAuthArgsType | SingleStepAuthArgsType) & { @@ -89,6 +92,7 @@ type RecoveryShareManagement = "USER_MANAGED" | "AWS_MANAGED" | "ENCLAVE"; export type AuthProvider = | "Cognito" | "Guest" + | "Backend" | "Google" | "EmailOtp" | "CustomJWT" diff --git a/packages/thirdweb/src/wallets/in-app/core/wallet/in-app-core.ts b/packages/thirdweb/src/wallets/in-app/core/wallet/in-app-core.ts index 2d937a4ee85..2a652b5bd71 100644 --- a/packages/thirdweb/src/wallets/in-app/core/wallet/in-app-core.ts +++ b/packages/thirdweb/src/wallets/in-app/core/wallet/in-app-core.ts @@ -26,7 +26,11 @@ export async function getOrCreateInAppWalletConnector( connectorFactory: (client: ThirdwebClient) => Promise, ecosystem?: Ecosystem, ) { - const key = stringify({ clientId: client.clientId, ecosystem }); + const key = stringify({ + clientId: client.clientId, + ecosystem, + partialSecretKey: client.secretKey?.slice(0, 5), + }); if (connectorCache.has(key)) { return connectorCache.get(key) as InAppConnector; } diff --git a/packages/thirdweb/src/wallets/in-app/native/auth/index.ts b/packages/thirdweb/src/wallets/in-app/native/auth/index.ts index 18574692df5..886aa4ee4ee 100644 --- a/packages/thirdweb/src/wallets/in-app/native/auth/index.ts +++ b/packages/thirdweb/src/wallets/in-app/native/auth/index.ts @@ -133,6 +133,17 @@ export async function preAuthenticate(args: PreAuthArgsType) { * verificationCode: "123456", * }); * ``` + * + * Authenticate to a backend account (only do this on your backend): + * ```ts + * import { authenticate } from "thirdweb/wallets/in-app"; + * + * const result = await authenticate({ + * client, + * strategy: "backend", + * walletSecret: "...", // Provided by your app + * }); + * ``` * @wallet */ export async function authenticate(args: AuthArgsType) { diff --git a/packages/thirdweb/src/wallets/in-app/native/native-connector.ts b/packages/thirdweb/src/wallets/in-app/native/native-connector.ts index 9e0e4c61167..cae76d45c66 100644 --- a/packages/thirdweb/src/wallets/in-app/native/native-connector.ts +++ b/packages/thirdweb/src/wallets/in-app/native/native-connector.ts @@ -4,6 +4,7 @@ import { nativeLocalStorage } from "../../../utils/storage/nativeStorage.js"; import type { Account } from "../../interfaces/wallet.js"; import { getUserStatus } from "../core/actions/get-enclave-user-status.js"; import { authEndpoint } from "../core/authentication/authEndpoint.js"; +import { backendAuthenticate } from "../core/authentication/backend.js"; import { ClientScopedStorage } from "../core/authentication/client-scoped-storage.js"; import { guestAuthenticate } from "../core/authentication/guest.js"; import { customJwt } from "../core/authentication/jwt.js"; @@ -171,6 +172,13 @@ export class InAppNativeConnector implements InAppConnector { storage: nativeLocalStorage, }); } + case "backend": { + return backendAuthenticate({ + client: this.client, + walletSecret: params.walletSecret, + ecosystem: params.ecosystem, + }); + } case "wallet": { return siweAuthenticate({ client: this.client, @@ -179,6 +187,11 @@ export class InAppNativeConnector implements InAppConnector { ecosystem: params.ecosystem, }); } + case "github": + case "twitch": + case "steam": + case "farcaster": + case "telegram": case "google": case "facebook": case "discord": diff --git a/packages/thirdweb/src/wallets/in-app/web/in-app.ts b/packages/thirdweb/src/wallets/in-app/web/in-app.ts index 30246a7d66f..6e90dd3b7e2 100644 --- a/packages/thirdweb/src/wallets/in-app/web/in-app.ts +++ b/packages/thirdweb/src/wallets/in-app/web/in-app.ts @@ -139,6 +139,20 @@ import { createInAppWallet } from "../core/wallet/in-app-core.js"; * }); * ``` * + * ### Connect to a backend account + * + * ```ts + * import { inAppWallet } from "thirdweb/wallets"; + * + * const wallet = inAppWallet(); + * + * const account = await wallet.connect({ + * client, + * strategy: "backend", + * walletSecret: "...", // Provided by your app + * }); + * ``` + * * ### Connect with custom JWT (any OIDC provider) * * You can use any OIDC provider to authenticate your users. Make sure to configure it in your dashboard under in-app wallet settings. diff --git a/packages/thirdweb/src/wallets/in-app/web/lib/auth/index.ts b/packages/thirdweb/src/wallets/in-app/web/lib/auth/index.ts index 7898597e45e..b19288d688b 100644 --- a/packages/thirdweb/src/wallets/in-app/web/lib/auth/index.ts +++ b/packages/thirdweb/src/wallets/in-app/web/lib/auth/index.ts @@ -140,6 +140,17 @@ export async function preAuthenticate(args: PreAuthArgsType) { * verificationCode: "123456", * }); * ``` + * + * Authenticate to a backend account (only do this on your backend): + * ```ts + * import { authenticate } from "thirdweb/wallets/in-app"; + * + * const result = await authenticate({ + * client, + * strategy: "backend", + * walletSecret: "...", // Provided by your app + * }); + * ``` * @wallet */ export async function authenticate(args: AuthArgsType) { diff --git a/packages/thirdweb/src/wallets/in-app/web/lib/web-connector.test.ts b/packages/thirdweb/src/wallets/in-app/web/lib/web-connector.test.ts new file mode 100644 index 00000000000..a1abf81a536 --- /dev/null +++ b/packages/thirdweb/src/wallets/in-app/web/lib/web-connector.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it, vi } from "vitest"; +import { TEST_CLIENT } from "~test/test-clients.js"; +import { TEST_ACCOUNT_A } from "~test/test-wallets.js"; +import { createWalletAdapter } from "../../../../adapters/wallet-adapter.js"; +import { ethereum } from "../../../../chains/chain-definitions/ethereum.js"; +import { backendAuthenticate } from "../../core/authentication/backend.js"; +import { guestAuthenticate } from "../../core/authentication/guest.js"; +import { siweAuthenticate } from "../../core/authentication/siwe.js"; +import { loginWithOauth } from "./auth/oauth.js"; +import { verifyOtp } from "./auth/otp.js"; +import { InAppWebConnector } from "./web-connector.js"; + +vi.mock("./auth/oauth"); +vi.mock("./auth/iframe-auth.ts", () => { + const Auth = vi.fn(); + Auth.prototype.loginWithAuthToken = vi.fn(() => { + return Promise.resolve({ + user: { + authDetails: { + recoveryShareManagement: "ENCLAVE", + userWalletId: "123", + }, + status: "Logged In, Wallet Initialized", + walletAddress: "0x123", + }, + }); + }); + return { Auth }; +}); +vi.mock("../../core/authentication/siwe"); +vi.mock("../../core/authentication/guest"); +vi.mock("../../core/authentication/backend"); +vi.mock("./auth/otp"); +vi.mock("../../core/authentication/authEndpoint"); +vi.mock("../../core/authentication/jwt"); +vi.mock("../../web/utils/iFrameCommunication/InAppWalletIframeCommunicator"); + +describe("InAppWebConnector.connect", () => { + const mockAuthToken = { + storedToken: { + authDetails: { + userWalletId: "123", + recoveryShareManagement: "ENCLAVE" as const, + }, + authProvider: "EmailOtp" as const, + cookieString: "mock-cookie", + developerClientId: TEST_CLIENT.clientId, + isNewUser: false, + jwtToken: "mock-jwt-token", + shouldStoreCookieString: true, + }, + }; + + const connector = new InAppWebConnector({ + client: TEST_CLIENT, + }); + const mockWallet = createWalletAdapter({ + adaptedAccount: TEST_ACCOUNT_A, + client: TEST_CLIENT, + chain: ethereum, + onDisconnect: () => {}, + switchChain: () => {}, + }); + const mockAccount = mockWallet.getAccount(); + if (!mockAccount) { + throw new Error("mockAccount is undefined"); + } + + it("should handle email authentication", async () => { + vi.mocked(verifyOtp).mockResolvedValueOnce(mockAuthToken); + + const result = await connector.connect({ + strategy: "email", + email: "test@example.com", + verificationCode: "123456", + }); + + expect(verifyOtp).toHaveBeenCalledWith({ + strategy: "email", + email: "test@example.com", + verificationCode: "123456", + client: TEST_CLIENT, + ecosystem: undefined, + }); + + expect(result).toBeDefined(); + }); + + it("should handle wallet authentication", async () => { + vi.mocked(siweAuthenticate).mockResolvedValueOnce(mockAuthToken); + + const mockWallet = createWalletAdapter({ + adaptedAccount: TEST_ACCOUNT_A, + client: TEST_CLIENT, + chain: ethereum, + onDisconnect: () => {}, + switchChain: () => {}, + }); + + await connector.connect({ + strategy: "wallet", + chain: ethereum, + wallet: mockWallet, + }); + + expect(siweAuthenticate).toHaveBeenCalledWith({ + wallet: mockWallet, + chain: ethereum, + client: TEST_CLIENT, + ecosystem: undefined, + }); + }); + + it("should handle guest authentication", async () => { + vi.mocked(guestAuthenticate).mockResolvedValueOnce(mockAuthToken); + + await connector.connect({ + strategy: "guest", + }); + + expect(guestAuthenticate).toHaveBeenCalled(); + }); + + it("should handle backend authentication", async () => { + vi.mocked(backendAuthenticate).mockResolvedValueOnce(mockAuthToken); + + await connector.connect({ + strategy: "backend", + walletSecret: "secret123", + }); + + expect(backendAuthenticate).toHaveBeenCalledWith({ + walletSecret: "secret123", + client: TEST_CLIENT, + ecosystem: undefined, + }); + }); + + it("should handle oauth authentication", async () => { + vi.mocked(loginWithOauth).mockResolvedValueOnce(mockAuthToken); + + await connector.connect({ + strategy: "google", + }); + + expect(loginWithOauth).toHaveBeenCalledWith({ + authOption: "google", + client: TEST_CLIENT, + ecosystem: undefined, + closeOpenedWindow: undefined, + openedWindow: undefined, + }); + }); + + it("should throw error for invalid strategy", async () => { + await expect( + connector.connect({ + // @ts-expect-error invalid strategy + strategy: "invalid", + }), + ).rejects.toThrow("Invalid param: invalid"); + }); +}); diff --git a/packages/thirdweb/src/wallets/in-app/web/lib/web-connector.ts b/packages/thirdweb/src/wallets/in-app/web/lib/web-connector.ts index c1a1d6afd56..57844579aef 100644 --- a/packages/thirdweb/src/wallets/in-app/web/lib/web-connector.ts +++ b/packages/thirdweb/src/wallets/in-app/web/lib/web-connector.ts @@ -5,6 +5,7 @@ import type { SocialAuthOption } from "../../../../wallets/types.js"; import type { Account } from "../../../interfaces/wallet.js"; import { getUserStatus } from "../../core/actions/get-enclave-user-status.js"; import { authEndpoint } from "../../core/authentication/authEndpoint.js"; +import { backendAuthenticate } from "../../core/authentication/backend.js"; import { ClientScopedStorage } from "../../core/authentication/client-scoped-storage.js"; import { guestAuthenticate } from "../../core/authentication/guest.js"; import { customJwt } from "../../core/authentication/jwt.js"; @@ -351,6 +352,13 @@ export class InAppWebConnector implements InAppConnector { storage: webLocalStorage, }); } + case "backend": { + return backendAuthenticate({ + client: this.client, + walletSecret: args.walletSecret, + ecosystem: this.ecosystem, + }); + } case "wallet": { return siweAuthenticate({ ecosystem: this.ecosystem, @@ -387,6 +395,7 @@ export class InAppWebConnector implements InAppConnector { const authToken = await this.passkeyAuth(args); return this.loginWithAuthToken(authToken); } + case "backend": case "phone": case "email": case "wallet": diff --git a/packages/thirdweb/src/wallets/types.ts b/packages/thirdweb/src/wallets/types.ts index e9ebbff2b60..4aa870be88a 100644 --- a/packages/thirdweb/src/wallets/types.ts +++ b/packages/thirdweb/src/wallets/types.ts @@ -39,6 +39,7 @@ export type OAuthOption = SocialAuthOption | "guest"; export const authOptions = [ ...socialAuthOptions, "guest", + "backend", "email", "phone", "passkey",