Skip to content

Commit 2d98fe2

Browse files
Support linking accounts with redirect mode (#8643)
1 parent 93bcdc5 commit 2d98fe2

12 files changed

Lines changed: 275 additions & 3 deletions

File tree

.changeset/eleven-boxes-type.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"thirdweb": patch
3+
---
4+
5+
Support linking accounts with redirect mode

apps/playground-web/src/components/in-app-wallet/profiles.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,14 @@ export function LinkAccount() {
4949
});
5050
};
5151

52+
const linkGithub = async () => {
53+
linkProfile({
54+
client: THIRDWEB_CLIENT,
55+
strategy: "github",
56+
mode: "redirect",
57+
});
58+
};
59+
5260
return (
5361
<div className="flex flex-col gap-4 p-6">
5462
{account ? (
@@ -83,6 +91,14 @@ export function LinkAccount() {
8391
>
8492
Link Passkey
8593
</Button>
94+
<Button
95+
className="rounded-full p-6"
96+
disabled={isPending}
97+
onClick={linkGithub}
98+
variant="default"
99+
>
100+
Link Github
101+
</Button>
86102
</>
87103
)}
88104
{error && <p className="text-red-500">Error: {error.message}</p>}

packages/thirdweb/src/exports/wallets/in-app.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ export {
8282
getUserEmail,
8383
getUserPhoneNumber,
8484
linkProfile,
85+
linkProfileWithRedirect,
8586
preAuthenticate,
8687
unlinkProfile,
8788
} from "../../wallets/in-app/web/lib/auth/index.js";

packages/thirdweb/src/react/web/hooks/wallets/useLinkProfile.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ export function useLinkProfile() {
9494
onSuccess() {
9595
setTimeout(() => {
9696
queryClient.invalidateQueries({ queryKey: ["profiles"] });
97-
}, 500);
97+
}, 1000);
9898
},
9999
});
100100
}

packages/thirdweb/src/wallets/connection/autoConnectCore.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { AsyncStorage } from "../../utils/storage/AsyncStorage.js";
44
import { timeoutPromise } from "../../utils/timeoutPromise.js";
55
import { isEcosystemWallet } from "../ecosystem/is-ecosystem-wallet.js";
66
import { ClientScopedStorage } from "../in-app/core/authentication/client-scoped-storage.js";
7+
import { linkAccount } from "../in-app/core/authentication/linkAccount.js";
78
import type {
89
AuthArgsType,
910
AuthStoredTokenWithCookieReturnType,
@@ -83,6 +84,24 @@ const _autoConnectCore = async ({
8384

8485
const urlToken = getUrlToken();
8586

87+
// Handle linking flow: autoconnect with stored credentials, then link the new profile
88+
if (urlToken?.authFlow === "link" && urlToken.authResult) {
89+
const linkingResult = await handleLinkingFlow({
90+
client: props.client,
91+
connectOverride,
92+
createWalletFn,
93+
manager,
94+
onConnect,
95+
props,
96+
setLastAuthProvider,
97+
storage,
98+
timeout,
99+
urlToken,
100+
wallets,
101+
});
102+
return linkingResult;
103+
}
104+
86105
// If an auth cookie is found and this site supports the wallet, we'll set the auth cookie in the client storage
87106
const wallet = wallets.find((w) => w.id === urlToken?.walletId);
88107
if (urlToken?.authCookie && wallet) {
@@ -223,6 +242,138 @@ const _autoConnectCore = async ({
223242
return autoConnected; // useQuery needs a return value
224243
};
225244

245+
/**
246+
* Handles the linking flow when returning from an OAuth redirect with authFlow=link.
247+
* This autoconnects using stored credentials, then links the new profile from the URL token.
248+
* @internal
249+
*/
250+
async function handleLinkingFlow(params: {
251+
client: ThirdwebClient;
252+
urlToken: NonNullable<ReturnType<typeof getUrlToken>>;
253+
wallets: Wallet[];
254+
storage: AsyncStorage;
255+
manager: ConnectionManager;
256+
onConnect?: (wallet: Wallet, connectedWallets: Wallet[]) => void;
257+
timeout: number;
258+
connectOverride?: (
259+
walletOrFn: Wallet | (() => Promise<Wallet>),
260+
) => Promise<Wallet | null>;
261+
createWalletFn: (id: WalletId) => Wallet;
262+
setLastAuthProvider?: (
263+
authProvider: AuthArgsType["strategy"],
264+
storage: AsyncStorage,
265+
) => Promise<void>;
266+
props: AutoConnectProps & { wallets: Wallet[] };
267+
}): Promise<boolean> {
268+
const {
269+
client,
270+
connectOverride,
271+
createWalletFn,
272+
manager,
273+
onConnect,
274+
props,
275+
setLastAuthProvider,
276+
storage,
277+
timeout,
278+
urlToken,
279+
wallets,
280+
} = params;
281+
282+
// Get stored wallet credentials (not from URL)
283+
const [storedConnectedWalletIds, storedActiveWalletId] = await Promise.all([
284+
getStoredConnectedWalletIds(storage),
285+
getStoredActiveWalletId(storage),
286+
]);
287+
const lastConnectedChain =
288+
(await getLastConnectedChain(storage)) || props.chain;
289+
290+
if (!storedActiveWalletId || !storedConnectedWalletIds) {
291+
console.warn("No stored wallet found for linking flow");
292+
manager.isAutoConnecting.setValue(false);
293+
return false;
294+
}
295+
296+
// Update auth provider if provided
297+
if (urlToken.authProvider) {
298+
await setLastAuthProvider?.(urlToken.authProvider, storage);
299+
}
300+
301+
// Find or create the active wallet from stored credentials
302+
const activeWallet =
303+
wallets.find((w) => w.id === storedActiveWalletId) ||
304+
createWalletFn(storedActiveWalletId);
305+
306+
// Autoconnect WITHOUT the URL token (use stored credentials)
307+
manager.activeWalletConnectionStatusStore.setValue("connecting");
308+
try {
309+
await timeoutPromise(
310+
handleWalletConnection({
311+
authResult: undefined, // Don't use URL token for connection
312+
client,
313+
lastConnectedChain,
314+
wallet: activeWallet,
315+
}),
316+
{
317+
message: `AutoConnect timeout: ${timeout}ms limit exceeded.`,
318+
ms: timeout,
319+
},
320+
);
321+
322+
await (connectOverride
323+
? connectOverride(activeWallet)
324+
: manager.connect(activeWallet, {
325+
accountAbstraction: props.accountAbstraction,
326+
client,
327+
}));
328+
} catch (e) {
329+
console.warn("Failed to auto-connect for linking:", e);
330+
manager.activeWalletConnectionStatusStore.setValue("disconnected");
331+
manager.isAutoConnecting.setValue(false);
332+
return false;
333+
}
334+
335+
// Now link the new profile using URL auth token
336+
const ecosystem = isEcosystemWallet(activeWallet)
337+
? {
338+
id: activeWallet.id,
339+
partnerId: activeWallet.getConfig()?.partnerId,
340+
}
341+
: undefined;
342+
343+
const clientStorage = new ClientScopedStorage({
344+
clientId: client.clientId,
345+
ecosystem,
346+
storage,
347+
});
348+
349+
try {
350+
await linkAccount({
351+
client,
352+
ecosystem,
353+
storage: clientStorage,
354+
tokenToLink: urlToken.authResult!.storedToken.cookieString,
355+
});
356+
} catch (e) {
357+
console.error("Failed to link profile after redirect:", e);
358+
// Continue - user is still connected, just linking failed
359+
}
360+
361+
manager.isAutoConnecting.setValue(false);
362+
363+
const connectedWallet = manager.activeWalletStore.getValue();
364+
const allConnectedWallets = manager.connectedWallets.getValue();
365+
if (connectedWallet) {
366+
try {
367+
onConnect?.(connectedWallet, allConnectedWallets);
368+
} catch (e) {
369+
console.error("Error calling onConnect callback:", e);
370+
}
371+
return true;
372+
}
373+
374+
return false;
375+
}
376+
226377
/**
227378
* @internal
228379
*/

packages/thirdweb/src/wallets/in-app/core/authentication/getLoginPath.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,14 @@ export const getLoginUrl = ({
2121
ecosystem,
2222
mode = "popup",
2323
redirectUrl,
24+
authFlow,
2425
}: {
2526
authOption: AuthOption;
2627
client: ThirdwebClient;
2728
ecosystem?: Ecosystem;
2829
mode?: "popup" | "redirect" | "window";
2930
redirectUrl?: string;
31+
authFlow?: "connect" | "link";
3032
}) => {
3133
if (mode === "popup" && redirectUrl) {
3234
throw new Error("Redirect URL is not supported for popup mode");
@@ -49,6 +51,9 @@ export const getLoginUrl = ({
4951
const formattedRedirectUrl = new URL(redirectUrl || window.location.href);
5052
formattedRedirectUrl.searchParams.set("walletId", ecosystem?.id || "inApp");
5153
formattedRedirectUrl.searchParams.set("authProvider", authOption);
54+
if (authFlow) {
55+
formattedRedirectUrl.searchParams.set("authFlow", authFlow);
56+
}
5257
baseUrl = `${baseUrl}&redirectUrl=${encodeURIComponent(formattedRedirectUrl.toString())}`;
5358
}
5459

packages/thirdweb/src/wallets/in-app/core/interfaces/connector.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ export interface InAppConnector {
2323
mode?: "redirect" | "popup" | "window",
2424
redirectUrl?: string,
2525
): Promise<void>;
26+
// Link a profile with redirect mode
27+
linkProfileWithRedirect?(
28+
strategy: SocialAuthOption,
29+
mode?: "redirect" | "window",
30+
redirectUrl?: string,
31+
): Promise<void>;
2632
// Login takes an auth token and connects a user with it
2733
loginWithAuthToken?(
2834
authResult: AuthStoredTokenWithCookieReturnType,

packages/thirdweb/src/wallets/in-app/web/lib/auth/index.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,51 @@ export async function authenticateWithRedirect(
191191
);
192192
}
193193

194+
/**
195+
* Links a new profile to the current user using OAuth redirect flow.
196+
*
197+
* This function initiates a redirect-based OAuth flow for linking a social account.
198+
* After the user completes authentication with the provider, they will be redirected
199+
* back to your app. The `autoConnect` function will automatically detect the linking
200+
* flow and complete the profile linking.
201+
*
202+
* @param args - The authentication arguments including strategy, client, and optional redirectUrl.
203+
* @returns A promise that resolves when the redirect is initiated.
204+
* @example
205+
* ```ts
206+
* import { linkProfileWithRedirect } from "thirdweb/wallets/in-app";
207+
*
208+
* await linkProfileWithRedirect({
209+
* client,
210+
* strategy: "google",
211+
* mode: "redirect",
212+
* redirectUrl: "https://example.org/callback",
213+
* });
214+
* // Browser will redirect to Google for authentication
215+
* // After auth, user is redirected back and autoConnect handles the linking
216+
* ```
217+
* @wallet
218+
*/
219+
export async function linkProfileWithRedirect(
220+
args: Omit<SocialAuthArgsType, "mode"> & {
221+
client: ThirdwebClient;
222+
ecosystem?: Ecosystem;
223+
mode?: "redirect" | "window";
224+
},
225+
) {
226+
const connector = await getInAppWalletConnector(args.client, args.ecosystem);
227+
if (!connector.linkProfileWithRedirect) {
228+
throw new Error(
229+
"linkProfileWithRedirect is not supported on this platform",
230+
);
231+
}
232+
return connector.linkProfileWithRedirect(
233+
args.strategy as SocialAuthOption,
234+
args.mode,
235+
args.redirectUrl,
236+
);
237+
}
238+
194239
/**
195240
* Connects a new profile (and new authentication method) to the current user.
196241
*

packages/thirdweb/src/wallets/in-app/web/lib/auth/oauth.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,12 @@ export async function loginWithOauthRedirect(options: {
3232
ecosystem?: Ecosystem;
3333
redirectUrl?: string;
3434
mode?: "redirect" | "popup" | "window";
35+
authFlow?: "connect" | "link";
3536
}): Promise<void> {
3637
const loginUrl = getLoginUrl({
3738
...options,
3839
mode: options.mode || "redirect",
40+
authFlow: options.authFlow,
3941
});
4042
if (options.mode === "redirect") {
4143
window.location.href = loginUrl;

packages/thirdweb/src/wallets/in-app/web/lib/get-url-token.test.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ describe.runIf(global.window !== undefined)("getUrlToken", () => {
4949

5050
expect(result).toEqual({
5151
authCookie: null,
52+
authFlow: null,
5253
authProvider: null,
5354
authResult: { token: "abc" },
5455
walletId: "123",
@@ -62,6 +63,7 @@ describe.runIf(global.window !== undefined)("getUrlToken", () => {
6263

6364
expect(result).toEqual({
6465
authCookie: "myCookie",
66+
authFlow: null,
6567
authProvider: null,
6668
authResult: undefined,
6769
walletId: "123",
@@ -79,6 +81,7 @@ describe.runIf(global.window !== undefined)("getUrlToken", () => {
7981

8082
expect(result).toEqual({
8183
authCookie: "myCookie",
84+
authFlow: null,
8285
authProvider: "provider1",
8386
authResult: { token: "xyz" },
8487
walletId: "123",

0 commit comments

Comments
 (0)