Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/eleven-boxes-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"thirdweb": patch
---

Support linking accounts with redirect mode
16 changes: 16 additions & 0 deletions apps/playground-web/src/components/in-app-wallet/profiles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ export function LinkAccount() {
});
};

const linkGithub = async () => {
linkProfile({
client: THIRDWEB_CLIENT,
strategy: "github",
mode: "redirect",
});
};

return (
<div className="flex flex-col gap-4 p-6">
{account ? (
Expand Down Expand Up @@ -83,6 +91,14 @@ export function LinkAccount() {
>
Link Passkey
</Button>
<Button
className="rounded-full p-6"
disabled={isPending}
onClick={linkGithub}
variant="default"
>
Link Github
</Button>
Comment thread
joaquim-verges marked this conversation as resolved.
</>
)}
{error && <p className="text-red-500">Error: {error.message}</p>}
Expand Down
1 change: 1 addition & 0 deletions packages/thirdweb/src/exports/wallets/in-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export {
getUserEmail,
getUserPhoneNumber,
linkProfile,
linkProfileWithRedirect,
preAuthenticate,
unlinkProfile,
} from "../../wallets/in-app/web/lib/auth/index.js";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ export function useLinkProfile() {
onSuccess() {
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ["profiles"] });
}, 500);
}, 1000);
},
});
}
151 changes: 151 additions & 0 deletions packages/thirdweb/src/wallets/connection/autoConnectCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<ReturnType<typeof getUrlToken>>;
wallets: Wallet[];
storage: AsyncStorage;
manager: ConnectionManager;
onConnect?: (wallet: Wallet, connectedWallets: Wallet[]) => void;
timeout: number;
connectOverride?: (
walletOrFn: Wallet | (() => Promise<Wallet>),
) => Promise<Wallet | null>;
createWalletFn: (id: WalletId) => Wallet;
setLastAuthProvider?: (
authProvider: AuthArgsType["strategy"],
storage: AsyncStorage,
) => Promise<void>;
props: AutoConnectProps & { wallets: Wallet[] };
}): Promise<boolean> {
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);

Comment thread
joaquim-verges marked this conversation as resolved.
// 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
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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())}`;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ export interface InAppConnector {
mode?: "redirect" | "popup" | "window",
redirectUrl?: string,
): Promise<void>;
// Link a profile with redirect mode
linkProfileWithRedirect?(
strategy: SocialAuthOption,
mode?: "redirect" | "window",
redirectUrl?: string,
): Promise<void>;
// Login takes an auth token and connects a user with it
loginWithAuthToken?(
authResult: AuthStoredTokenWithCookieReturnType,
Expand Down
45 changes: 45 additions & 0 deletions packages/thirdweb/src/wallets/in-app/web/lib/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SocialAuthArgsType, "mode"> & {
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.
*
Expand Down
2 changes: 2 additions & 0 deletions packages/thirdweb/src/wallets/in-app/web/lib/auth/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,12 @@ export async function loginWithOauthRedirect(options: {
ecosystem?: Ecosystem;
redirectUrl?: string;
mode?: "redirect" | "popup" | "window";
authFlow?: "connect" | "link";
}): Promise<void> {
const loginUrl = getLoginUrl({
...options,
mode: options.mode || "redirect",
authFlow: options.authFlow,
});
if (options.mode === "redirect") {
window.location.href = loginUrl;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export function getUrlToken():
authResult?: AuthStoredTokenWithCookieReturnType;
authProvider?: AuthOption;
authCookie?: string;
authFlow?: "connect" | "link";
}
| undefined {
if (typeof document === "undefined") {
Expand All @@ -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 = (() => {
Expand All @@ -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 };
Comment thread
graphite-app[bot] marked this conversation as resolved.
}
return undefined;
}
Loading
Loading