From 7118affed4f88fdb77bcd29daa203feb166ebb86 Mon Sep 17 00:00:00 2001 From: Joaquim Verges Date: Wed, 21 Jan 2026 09:24:20 +1300 Subject: [PATCH 1/2] Support linking accounts with redirect mode --- .changeset/eleven-boxes-type.md | 5 + .../src/components/in-app-wallet/profiles.tsx | 16 ++ .../thirdweb/src/exports/wallets/in-app.ts | 1 + .../react/web/hooks/wallets/useLinkProfile.ts | 2 +- .../src/wallets/connection/autoConnectCore.ts | 151 ++++++++++++++++++ .../core/authentication/getLoginPath.ts | 5 + .../in-app/core/interfaces/connector.ts | 6 + .../src/wallets/in-app/web/lib/auth/index.ts | 45 ++++++ .../src/wallets/in-app/web/lib/auth/oauth.ts | 2 + .../wallets/in-app/web/lib/get-url-token.ts | 5 +- .../wallets/in-app/web/lib/web-connector.ts | 37 ++++- 11 files changed, 272 insertions(+), 3 deletions(-) create mode 100644 .changeset/eleven-boxes-type.md diff --git a/.changeset/eleven-boxes-type.md b/.changeset/eleven-boxes-type.md new file mode 100644 index 00000000000..cbf607b6384 --- /dev/null +++ b/.changeset/eleven-boxes-type.md @@ -0,0 +1,5 @@ +--- +"thirdweb": patch +--- + +Support linking accounts with redirect mode diff --git a/apps/playground-web/src/components/in-app-wallet/profiles.tsx b/apps/playground-web/src/components/in-app-wallet/profiles.tsx index 74b803a8f19..d4a8c6d3588 100644 --- a/apps/playground-web/src/components/in-app-wallet/profiles.tsx +++ b/apps/playground-web/src/components/in-app-wallet/profiles.tsx @@ -49,6 +49,14 @@ export function LinkAccount() { }); }; + const linkGithub = async () => { + linkProfile({ + client: THIRDWEB_CLIENT, + strategy: "github", + mode: "redirect", + }); + }; + return (
{account ? ( @@ -83,6 +91,14 @@ export function LinkAccount() { > Link Passkey + )} {error &&

Error: {error.message}

} diff --git a/packages/thirdweb/src/exports/wallets/in-app.ts b/packages/thirdweb/src/exports/wallets/in-app.ts index 2f1c827b775..43ca05abd67 100644 --- a/packages/thirdweb/src/exports/wallets/in-app.ts +++ b/packages/thirdweb/src/exports/wallets/in-app.ts @@ -82,6 +82,7 @@ export { getUserEmail, getUserPhoneNumber, linkProfile, + linkProfileWithRedirect, preAuthenticate, unlinkProfile, } from "../../wallets/in-app/web/lib/auth/index.js"; diff --git a/packages/thirdweb/src/react/web/hooks/wallets/useLinkProfile.ts b/packages/thirdweb/src/react/web/hooks/wallets/useLinkProfile.ts index 7be8d692b34..ab1a2b47f3c 100644 --- a/packages/thirdweb/src/react/web/hooks/wallets/useLinkProfile.ts +++ b/packages/thirdweb/src/react/web/hooks/wallets/useLinkProfile.ts @@ -94,7 +94,7 @@ export function useLinkProfile() { onSuccess() { setTimeout(() => { queryClient.invalidateQueries({ queryKey: ["profiles"] }); - }, 500); + }, 1000); }, }); } diff --git a/packages/thirdweb/src/wallets/connection/autoConnectCore.ts b/packages/thirdweb/src/wallets/connection/autoConnectCore.ts index 60d5f0d968e..a08776a08f4 100644 --- a/packages/thirdweb/src/wallets/connection/autoConnectCore.ts +++ b/packages/thirdweb/src/wallets/connection/autoConnectCore.ts @@ -4,6 +4,7 @@ import type { AsyncStorage } from "../../utils/storage/AsyncStorage.js"; import { timeoutPromise } from "../../utils/timeoutPromise.js"; import { isEcosystemWallet } from "../ecosystem/is-ecosystem-wallet.js"; import { ClientScopedStorage } from "../in-app/core/authentication/client-scoped-storage.js"; +import { linkAccount } from "../in-app/core/authentication/linkAccount.js"; import type { AuthArgsType, AuthStoredTokenWithCookieReturnType, @@ -83,6 +84,24 @@ const _autoConnectCore = async ({ const urlToken = getUrlToken(); + // Handle linking flow: autoconnect with stored credentials, then link the new profile + if (urlToken?.authFlow === "link" && urlToken.authResult) { + const linkingResult = await handleLinkingFlow({ + client: props.client, + connectOverride, + createWalletFn, + manager, + onConnect, + props, + setLastAuthProvider, + storage, + timeout, + urlToken, + wallets, + }); + return linkingResult; + } + // If an auth cookie is found and this site supports the wallet, we'll set the auth cookie in the client storage const wallet = wallets.find((w) => w.id === urlToken?.walletId); if (urlToken?.authCookie && wallet) { @@ -223,6 +242,138 @@ const _autoConnectCore = async ({ return autoConnected; // useQuery needs a return value }; +/** + * Handles the linking flow when returning from an OAuth redirect with authFlow=link. + * This autoconnects using stored credentials, then links the new profile from the URL token. + * @internal + */ +async function handleLinkingFlow(params: { + client: ThirdwebClient; + urlToken: NonNullable>; + wallets: Wallet[]; + storage: AsyncStorage; + manager: ConnectionManager; + onConnect?: (wallet: Wallet, connectedWallets: Wallet[]) => void; + timeout: number; + connectOverride?: ( + walletOrFn: Wallet | (() => Promise), + ) => Promise; + createWalletFn: (id: WalletId) => Wallet; + setLastAuthProvider?: ( + authProvider: AuthArgsType["strategy"], + storage: AsyncStorage, + ) => Promise; + props: AutoConnectProps & { wallets: Wallet[] }; +}): Promise { + const { + client, + connectOverride, + createWalletFn, + manager, + onConnect, + props, + setLastAuthProvider, + storage, + timeout, + urlToken, + wallets, + } = params; + + // Get stored wallet credentials (not from URL) + const [storedConnectedWalletIds, storedActiveWalletId] = await Promise.all([ + getStoredConnectedWalletIds(storage), + getStoredActiveWalletId(storage), + ]); + const lastConnectedChain = + (await getLastConnectedChain(storage)) || props.chain; + + if (!storedActiveWalletId || !storedConnectedWalletIds) { + console.warn("No stored wallet found for linking flow"); + manager.isAutoConnecting.setValue(false); + return false; + } + + // Update auth provider if provided + if (urlToken.authProvider) { + await setLastAuthProvider?.(urlToken.authProvider, storage); + } + + // Find or create the active wallet from stored credentials + const activeWallet = + wallets.find((w) => w.id === storedActiveWalletId) || + createWalletFn(storedActiveWalletId); + + // Autoconnect WITHOUT the URL token (use stored credentials) + manager.activeWalletConnectionStatusStore.setValue("connecting"); + try { + await timeoutPromise( + handleWalletConnection({ + authResult: undefined, // Don't use URL token for connection + client, + lastConnectedChain, + wallet: activeWallet, + }), + { + message: `AutoConnect timeout: ${timeout}ms limit exceeded.`, + ms: timeout, + }, + ); + + await (connectOverride + ? connectOverride(activeWallet) + : manager.connect(activeWallet, { + accountAbstraction: props.accountAbstraction, + client, + })); + } catch (e) { + console.warn("Failed to auto-connect for linking:", e); + manager.activeWalletConnectionStatusStore.setValue("disconnected"); + manager.isAutoConnecting.setValue(false); + return false; + } + + // Now link the new profile using URL auth token + const ecosystem = isEcosystemWallet(activeWallet) + ? { + id: activeWallet.id, + partnerId: activeWallet.getConfig()?.partnerId, + } + : undefined; + + const clientStorage = new ClientScopedStorage({ + clientId: client.clientId, + ecosystem, + storage, + }); + + try { + await linkAccount({ + client, + ecosystem, + storage: clientStorage, + tokenToLink: urlToken.authResult!.storedToken.cookieString, + }); + } catch (e) { + console.error("Failed to link profile after redirect:", e); + // Continue - user is still connected, just linking failed + } + + manager.isAutoConnecting.setValue(false); + + const connectedWallet = manager.activeWalletStore.getValue(); + const allConnectedWallets = manager.connectedWallets.getValue(); + if (connectedWallet) { + try { + onConnect?.(connectedWallet, allConnectedWallets); + } catch (e) { + console.error("Error calling onConnect callback:", e); + } + return true; + } + + return false; +} + /** * @internal */ diff --git a/packages/thirdweb/src/wallets/in-app/core/authentication/getLoginPath.ts b/packages/thirdweb/src/wallets/in-app/core/authentication/getLoginPath.ts index 2dc6705dcd4..d259821643d 100644 --- a/packages/thirdweb/src/wallets/in-app/core/authentication/getLoginPath.ts +++ b/packages/thirdweb/src/wallets/in-app/core/authentication/getLoginPath.ts @@ -21,12 +21,14 @@ export const getLoginUrl = ({ ecosystem, mode = "popup", redirectUrl, + authFlow, }: { authOption: AuthOption; client: ThirdwebClient; ecosystem?: Ecosystem; mode?: "popup" | "redirect" | "window"; redirectUrl?: string; + authFlow?: "connect" | "link"; }) => { if (mode === "popup" && redirectUrl) { throw new Error("Redirect URL is not supported for popup mode"); @@ -49,6 +51,9 @@ export const getLoginUrl = ({ const formattedRedirectUrl = new URL(redirectUrl || window.location.href); formattedRedirectUrl.searchParams.set("walletId", ecosystem?.id || "inApp"); formattedRedirectUrl.searchParams.set("authProvider", authOption); + if (authFlow) { + formattedRedirectUrl.searchParams.set("authFlow", authFlow); + } baseUrl = `${baseUrl}&redirectUrl=${encodeURIComponent(formattedRedirectUrl.toString())}`; } diff --git a/packages/thirdweb/src/wallets/in-app/core/interfaces/connector.ts b/packages/thirdweb/src/wallets/in-app/core/interfaces/connector.ts index f4509324f5f..61c4e8ee4b2 100644 --- a/packages/thirdweb/src/wallets/in-app/core/interfaces/connector.ts +++ b/packages/thirdweb/src/wallets/in-app/core/interfaces/connector.ts @@ -23,6 +23,12 @@ export interface InAppConnector { mode?: "redirect" | "popup" | "window", redirectUrl?: string, ): Promise; + // Link a profile with redirect mode + linkProfileWithRedirect?( + strategy: SocialAuthOption, + mode?: "redirect" | "window", + redirectUrl?: string, + ): Promise; // Login takes an auth token and connects a user with it loginWithAuthToken?( authResult: AuthStoredTokenWithCookieReturnType, 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 50a15fd98ef..908aaa0f71d 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 @@ -191,6 +191,51 @@ export async function authenticateWithRedirect( ); } +/** + * Links a new profile to the current user using OAuth redirect flow. + * + * This function initiates a redirect-based OAuth flow for linking a social account. + * After the user completes authentication with the provider, they will be redirected + * back to your app. The `autoConnect` function will automatically detect the linking + * flow and complete the profile linking. + * + * @param args - The authentication arguments including strategy, client, and optional redirectUrl. + * @returns A promise that resolves when the redirect is initiated. + * @example + * ```ts + * import { linkProfileWithRedirect } from "thirdweb/wallets/in-app"; + * + * await linkProfileWithRedirect({ + * client, + * strategy: "google", + * mode: "redirect", + * redirectUrl: "https://example.org/callback", + * }); + * // Browser will redirect to Google for authentication + * // After auth, user is redirected back and autoConnect handles the linking + * ``` + * @wallet + */ +export async function linkProfileWithRedirect( + args: Omit & { + client: ThirdwebClient; + ecosystem?: Ecosystem; + mode?: "redirect" | "window"; + }, +) { + const connector = await getInAppWalletConnector(args.client, args.ecosystem); + if (!connector.linkProfileWithRedirect) { + throw new Error( + "linkProfileWithRedirect is not supported on this platform", + ); + } + return connector.linkProfileWithRedirect( + args.strategy as SocialAuthOption, + args.mode, + args.redirectUrl, + ); +} + /** * Connects a new profile (and new authentication method) to the current user. * diff --git a/packages/thirdweb/src/wallets/in-app/web/lib/auth/oauth.ts b/packages/thirdweb/src/wallets/in-app/web/lib/auth/oauth.ts index 41d241f1363..dd13dd1308f 100644 --- a/packages/thirdweb/src/wallets/in-app/web/lib/auth/oauth.ts +++ b/packages/thirdweb/src/wallets/in-app/web/lib/auth/oauth.ts @@ -32,10 +32,12 @@ export async function loginWithOauthRedirect(options: { ecosystem?: Ecosystem; redirectUrl?: string; mode?: "redirect" | "popup" | "window"; + authFlow?: "connect" | "link"; }): Promise { const loginUrl = getLoginUrl({ ...options, mode: options.mode || "redirect", + authFlow: options.authFlow, }); if (options.mode === "redirect") { window.location.href = loginUrl; diff --git a/packages/thirdweb/src/wallets/in-app/web/lib/get-url-token.ts b/packages/thirdweb/src/wallets/in-app/web/lib/get-url-token.ts index bee4daa1f31..ee9293f5bd8 100644 --- a/packages/thirdweb/src/wallets/in-app/web/lib/get-url-token.ts +++ b/packages/thirdweb/src/wallets/in-app/web/lib/get-url-token.ts @@ -11,6 +11,7 @@ export function getUrlToken(): authResult?: AuthStoredTokenWithCookieReturnType; authProvider?: AuthOption; authCookie?: string; + authFlow?: "connect" | "link"; } | undefined { if (typeof document === "undefined") { @@ -24,6 +25,7 @@ export function getUrlToken(): const walletId = params.get("walletId") as WalletId | undefined; const authProvider = params.get("authProvider") as AuthOption | undefined; const authCookie = params.get("authCookie") as string | undefined; + const authFlow = params.get("authFlow") as "connect" | "link" | undefined; if ((authCookie || authResultString) && walletId) { const authResult = (() => { @@ -35,12 +37,13 @@ export function getUrlToken(): params.delete("walletId"); params.delete("authProvider"); params.delete("authCookie"); + params.delete("authFlow"); window.history.pushState( {}, "", `${window.location.pathname}?${params.toString()}`, ); - return { authCookie, authProvider, authResult, walletId }; + return { authCookie, authFlow, authProvider, authResult, walletId }; } return undefined; } 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 efb70e6f9c9..adb8ce072c7 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 @@ -3,7 +3,10 @@ import { getThirdwebBaseUrl } from "../../../../utils/domains.js"; import type { AsyncStorage } from "../../../../utils/storage/AsyncStorage.js"; import { inMemoryStorage } from "../../../../utils/storage/inMemoryStorage.js"; import { webLocalStorage } from "../../../../utils/storage/webStorage.js"; -import type { SocialAuthOption } from "../../../../wallets/types.js"; +import { + type SocialAuthOption, + socialAuthOptions, +} 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"; @@ -464,7 +467,39 @@ export class InAppWebConnector implements InAppConnector { }); } + async linkProfileWithRedirect( + strategy: SocialAuthOption, + mode?: "redirect" | "window", + redirectUrl?: string, + ): Promise { + return loginWithOauthRedirect({ + authOption: strategy, + client: this.client, + ecosystem: this.ecosystem, + mode, + redirectUrl, + authFlow: "link", + }); + } + async linkProfile(args: AuthArgsType) { + // Check if this is social auth with redirect mode + if ( + "strategy" in args && + socialAuthOptions.includes(args.strategy as SocialAuthOption) && + "mode" in args && + args.mode !== "popup" && + args.mode !== undefined + ) { + await this.linkProfileWithRedirect( + args.strategy as SocialAuthOption, + args.mode as "redirect" | "window", + "redirectUrl" in args ? (args.redirectUrl as string) : undefined, + ); + // Will redirect, return empty (code won't reach here) + return []; + } + const { storedToken } = await this.authenticate(args); return await linkAccount({ client: args.client, From 17d976a1463a10d80891e436feaf0f91dd8111ea Mon Sep 17 00:00:00 2001 From: Joaquim Verges Date: Wed, 21 Jan 2026 10:47:10 +1300 Subject: [PATCH 2/2] fix test --- .../thirdweb/src/wallets/in-app/web/lib/get-url-token.test.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/thirdweb/src/wallets/in-app/web/lib/get-url-token.test.tsx b/packages/thirdweb/src/wallets/in-app/web/lib/get-url-token.test.tsx index 3209675cfb6..b001ede5806 100644 --- a/packages/thirdweb/src/wallets/in-app/web/lib/get-url-token.test.tsx +++ b/packages/thirdweb/src/wallets/in-app/web/lib/get-url-token.test.tsx @@ -49,6 +49,7 @@ describe.runIf(global.window !== undefined)("getUrlToken", () => { expect(result).toEqual({ authCookie: null, + authFlow: null, authProvider: null, authResult: { token: "abc" }, walletId: "123", @@ -62,6 +63,7 @@ describe.runIf(global.window !== undefined)("getUrlToken", () => { expect(result).toEqual({ authCookie: "myCookie", + authFlow: null, authProvider: null, authResult: undefined, walletId: "123", @@ -79,6 +81,7 @@ describe.runIf(global.window !== undefined)("getUrlToken", () => { expect(result).toEqual({ authCookie: "myCookie", + authFlow: null, authProvider: "provider1", authResult: { token: "xyz" }, walletId: "123",