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.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",
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,