From 3848327373e49aa83c5902e6a16d5b8e96cf1eeb Mon Sep 17 00:00:00 2001 From: gregfromstl <17715009+gregfromstl@users.noreply.github.com> Date: Thu, 25 Jul 2024 23:33:31 +0000 Subject: [PATCH] feat(thirdweb): adds redirect oauth (#3822) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### TL;DR Added support for OAuth redirects in the In-App wallet authentication flow. ### What changed? 1. Introduced `auth.mode` with options ` --- --- ## PR-Codex overview This PR introduces the ability to open OAuth windows as a redirect, beneficial for embedded applications like Telegram web apps. ### Detailed summary - Added `mode: "redirect"` option for OAuth windows - Implemented functions for OAuth redirection - Updated authentication options for in-app wallets > The following files were skipped due to too many changes: `packages/thirdweb/src/react/core/hooks/wallets/useAutoConnect.ts`, `packages/thirdweb/src/wallets/in-app/web/lib/web-connector.ts` > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` --- .changeset/tall-donuts-relate.md | 16 +++++++++ apps/playground-web/src/lib/constants.ts | 1 + .../core/hooks/wallets/useAutoConnect.ts | 20 +++++++++-- .../shared/ConnectWalletSocialOptions.tsx | 22 +++++++++--- .../react/web/wallets/shared/SocialLogin.tsx | 21 ++++++++++- ...penOauthSignInWindow.ts => oauthSignIn.ts} | 3 +- .../thirdweb/src/wallets/ecosystem/types.ts | 3 ++ .../in-app/core/interfaces/connector.ts | 6 ++++ .../src/wallets/in-app/core/wallet/index.ts | 22 ++++++++++++ .../src/wallets/in-app/core/wallet/types.ts | 4 +++ .../thirdweb/src/wallets/in-app/web/in-app.ts | 10 ++++++ .../src/wallets/in-app/web/lib/auth/index.ts | 17 +++++++-- .../src/wallets/in-app/web/lib/auth/oauth.ts | 12 +++++++ .../wallets/in-app/web/lib/get-url-token.ts | 36 +++++++++++++++++++ .../wallets/in-app/web/lib/web-connector.ts | 22 +++++++++--- 15 files changed, 198 insertions(+), 17 deletions(-) create mode 100644 .changeset/tall-donuts-relate.md rename packages/thirdweb/src/react/web/wallets/shared/{openOauthSignInWindow.ts => oauthSignIn.ts} (99%) create mode 100644 packages/thirdweb/src/wallets/in-app/web/lib/get-url-token.ts diff --git a/.changeset/tall-donuts-relate.md b/.changeset/tall-donuts-relate.md new file mode 100644 index 00000000000..0068527101a --- /dev/null +++ b/.changeset/tall-donuts-relate.md @@ -0,0 +1,16 @@ +--- +"thirdweb": minor +--- + +Adds the ability to open OAuth windows as a redirect. This is useful for embedded applications such as telegram web apps. + +Be sure to include your domain in the allowlisted domains for your client ID. + +```ts +import { inAppWallet } from "thirdweb/wallets"; +const wallet = inAppWallet({ + auth: { + mode: "redirect" + } +}); +``` diff --git a/apps/playground-web/src/lib/constants.ts b/apps/playground-web/src/lib/constants.ts index 281bc7dd1b1..ae5f9ba541b 100644 --- a/apps/playground-web/src/lib/constants.ts +++ b/apps/playground-web/src/lib/constants.ts @@ -16,6 +16,7 @@ export const WALLETS = [ "passkey", "phone", ], + mode: "redirect", }, }), createWallet("io.metamask"), diff --git a/packages/thirdweb/src/react/core/hooks/wallets/useAutoConnect.ts b/packages/thirdweb/src/react/core/hooks/wallets/useAutoConnect.ts index e3b39cd9537..8043330230c 100644 --- a/packages/thirdweb/src/react/core/hooks/wallets/useAutoConnect.ts +++ b/packages/thirdweb/src/react/core/hooks/wallets/useAutoConnect.ts @@ -1,5 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import type { AsyncStorage } from "../../../../utils/storage/AsyncStorage.js"; +import { getUrlToken } from "../../../../wallets/in-app/web/lib/get-url-token.js"; import type { Wallet } from "../../../../wallets/interfaces/wallet.js"; import { type ConnectionManager, @@ -7,6 +8,7 @@ import { getStoredActiveWalletId, getStoredConnectedWalletIds, } from "../../../../wallets/manager/index.js"; +import { setLastAuthProvider } from "../../utils/storage.js"; import { timeoutPromise } from "../../utils/timeoutPromise.js"; import type { AutoConnectProps } from "../connection/types.js"; import { useConnectCore } from "./useConnect.js"; @@ -32,12 +34,23 @@ export function useAutoConnectCore( const autoConnect = async (): Promise => { let autoConnected = false; isAutoConnecting.setValue(true); - const [lastConnectedWalletIds, lastActiveWalletId] = await Promise.all([ + let [lastConnectedWalletIds, lastActiveWalletId] = await Promise.all([ getStoredConnectedWalletIds(storage), getStoredActiveWalletId(storage), ]); - // if no wallets were last connected + const { authResult, walletId, authProvider } = getUrlToken(); + if (authResult && walletId) { + lastActiveWalletId = walletId; + lastConnectedWalletIds = lastConnectedWalletIds?.includes(walletId) + ? lastConnectedWalletIds + : [walletId, ...(lastConnectedWalletIds || [])]; + } + if (authProvider) { + await setLastAuthProvider(authProvider, storage); + } + + // if no wallets were last connected or we didn't receive an auth token if (!lastConnectedWalletIds) { return autoConnected; } @@ -48,6 +61,7 @@ export function useAutoConnectCore( return wallet.autoConnect({ client: props.client, chain: lastConnectedChain ?? undefined, + authResult, }); } @@ -61,7 +75,7 @@ export function useAutoConnectCore( setConnectionStatus("connecting"); // only set connecting status if we are connecting the last active EOA await timeoutPromise(handleWalletConnection(activeWallet), { ms: timeout, - message: `AutoConnect timeout : ${timeout}ms limit exceeded.`, + message: `AutoConnect timeout: ${timeout}ms limit exceeded.`, }); // connected wallet could be activeWallet or smart wallet diff --git a/packages/thirdweb/src/react/web/wallets/shared/ConnectWalletSocialOptions.tsx b/packages/thirdweb/src/react/web/wallets/shared/ConnectWalletSocialOptions.tsx index 2176d8cb36c..82900f562c8 100644 --- a/packages/thirdweb/src/react/web/wallets/shared/ConnectWalletSocialOptions.tsx +++ b/packages/thirdweb/src/react/web/wallets/shared/ConnectWalletSocialOptions.tsx @@ -7,6 +7,7 @@ import type { ThirdwebClient } from "../../../../client/client.js"; import { webLocalStorage } from "../../../../utils/storage/webStorage.js"; import { getEcosystemWalletAuthOptions } from "../../../../wallets/ecosystem/get-ecosystem-wallet-auth-options.js"; import { isEcosystemWallet } from "../../../../wallets/ecosystem/is-ecosystem-wallet.js"; +import { loginWithOauthRedirect } from "../../../../wallets/in-app/web/lib/auth/oauth.js"; import type { Account, Wallet } from "../../../../wallets/interfaces/wallet.js"; import { type AuthOption, @@ -37,7 +38,7 @@ import { InputSelectionUI } from "../in-app/InputSelectionUI.js"; import { validateEmail } from "../in-app/validateEmail.js"; import { LoadingScreen } from "./LoadingScreen.js"; import type { InAppWalletLocale } from "./locale/types.js"; -import { openOauthSignInWindow } from "./openOauthSignInWindow.js"; +import { openOauthSignInWindow } from "./oauthSignIn.js"; export type ConnectWalletSelectUIState = | undefined @@ -164,6 +165,19 @@ export const ConnectWalletSocialOptions = ( // Need to trigger login on button click to avoid popup from being blocked const handleSocialLogin = async (strategy: SocialAuthOption) => { + const walletConfig = wallet.getConfig(); + if ( + walletConfig && + "auth" in walletConfig && + walletConfig?.auth?.mode === "redirect" + ) { + return loginWithOauthRedirect({ + authOption: strategy, + client: props.client, + ecosystem: ecosystemInfo, + }); + } + try { const socialLoginWindow = openOauthSignInWindow({ authOption: strategy, @@ -205,10 +219,10 @@ export const ConnectWalletSocialOptions = ( props.select(); // show Connect UI - // Note: do not call done() here, it will be called InAppWalletSocialLogin component - // we simply trigger the connect and save promise here - its resolution is handled in InAppWalletSocialLogin + // Note: do not call done() here, it will be called SocialLogin component + // we simply trigger the connect and save promise here - its resolution is handled in SocialLogin } catch (e) { - console.error(`Error sign in with ${strategy}`, e); + console.error(`Error signing in with ${strategy}`, e); } }; diff --git a/packages/thirdweb/src/react/web/wallets/shared/SocialLogin.tsx b/packages/thirdweb/src/react/web/wallets/shared/SocialLogin.tsx index 7ae5d94fbfb..a10ce43586b 100644 --- a/packages/thirdweb/src/react/web/wallets/shared/SocialLogin.tsx +++ b/packages/thirdweb/src/react/web/wallets/shared/SocialLogin.tsx @@ -5,6 +5,7 @@ import type { ThirdwebClient } from "../../../../client/client.js"; import { webLocalStorage } from "../../../../utils/storage/webStorage.js"; import { isEcosystemWallet } from "../../../../wallets/ecosystem/is-ecosystem-wallet.js"; import type { InAppWalletSocialAuth } from "../../../../wallets/in-app/core/wallet/types.js"; +import { loginWithOauthRedirect } from "../../../../wallets/in-app/web/lib/auth/oauth.js"; import type { Wallet } from "../../../../wallets/interfaces/wallet.js"; import { useCustomTheme } from "../../../core/design-system/CustomThemeProvider.js"; import { setLastAuthProvider } from "../../../core/utils/storage.js"; @@ -15,7 +16,7 @@ import { Button } from "../../ui/components/buttons.js"; import { Text } from "../../ui/components/text.js"; import type { ConnectWalletSelectUIState } from "./ConnectWalletSocialOptions.js"; import type { InAppWalletLocale } from "./locale/types.js"; -import { openOauthSignInWindow } from "./openOauthSignInWindow.js"; +import { openOauthSignInWindow } from "./oauthSignIn.js"; /** * @internal @@ -42,6 +43,24 @@ export function SocialLogin(props: { ); const handleSocialLogin = async () => { + const walletConfig = wallet.getConfig(); + if ( + walletConfig && + "auth" in walletConfig && + walletConfig?.auth?.mode === "redirect" + ) { + return loginWithOauthRedirect({ + authOption: props.socialAuth, + client: props.client, + ecosystem: isEcosystemWallet(wallet) + ? { + id: wallet.id, + partnerId: wallet.getConfig()?.partnerId, + } + : undefined, + }); + } + try { const socialWindow = openOauthSignInWindow({ authOption: props.socialAuth, diff --git a/packages/thirdweb/src/react/web/wallets/shared/openOauthSignInWindow.ts b/packages/thirdweb/src/react/web/wallets/shared/oauthSignIn.ts similarity index 99% rename from packages/thirdweb/src/react/web/wallets/shared/openOauthSignInWindow.ts rename to packages/thirdweb/src/react/web/wallets/shared/oauthSignIn.ts index 4f38f55ad4d..5c9fd4b3d6f 100644 --- a/packages/thirdweb/src/react/web/wallets/shared/openOauthSignInWindow.ts +++ b/packages/thirdweb/src/react/web/wallets/shared/oauthSignIn.ts @@ -41,7 +41,6 @@ function getOauthLoginPath( } /** - * * @internal */ export function openOauthSignInWindow({ @@ -137,7 +136,7 @@ const spinnerWindowHtml = ` @keyframes spin { 100% { transform: rotate(360deg); - } + } } `; diff --git a/packages/thirdweb/src/wallets/ecosystem/types.ts b/packages/thirdweb/src/wallets/ecosystem/types.ts index 1aedc17aa42..2fcdcc72202 100644 --- a/packages/thirdweb/src/wallets/ecosystem/types.ts +++ b/packages/thirdweb/src/wallets/ecosystem/types.ts @@ -6,6 +6,9 @@ import type { Ecosystem } from "../in-app/web/types.js"; export type EcosystemWalletCreationOptions = { partnerId?: string; + auth?: { + mode?: "popup" | "redirect"; + }; }; export type EcosystemWalletConnectionOptions = InAppWalletConnectionOptions & { 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 ed875c87451..27a11d0ef2d 100644 --- a/packages/thirdweb/src/wallets/in-app/core/interfaces/connector.ts +++ b/packages/thirdweb/src/wallets/in-app/core/interfaces/connector.ts @@ -1,7 +1,9 @@ +import type { SocialAuthOption } from "../../../../wallets/types.js"; import type { Account } from "../../../interfaces/wallet.js"; import type { AuthArgsType, AuthLoginReturnType, + AuthStoredTokenWithCookieReturnType, GetUser, LogoutReturnType, PreAuthArgsType, @@ -12,6 +14,10 @@ export interface InAppConnector { getUser(): Promise; getAccount(): Promise; preAuthenticate(args: PreAuthArgsType): Promise; + authenticateWithRedirect?(strategy: SocialAuthOption): void; + loginWithAuthToken?( + authResult: AuthStoredTokenWithCookieReturnType, + ): Promise; authenticate(args: AuthArgsType): Promise; logout(): Promise; } diff --git a/packages/thirdweb/src/wallets/in-app/core/wallet/index.ts b/packages/thirdweb/src/wallets/in-app/core/wallet/index.ts index cba4feeb6cb..3f9a02579f3 100644 --- a/packages/thirdweb/src/wallets/in-app/core/wallet/index.ts +++ b/packages/thirdweb/src/wallets/in-app/core/wallet/index.ts @@ -1,6 +1,10 @@ import { ethereum } from "../../../../chains/chain-definitions/ethereum.js"; import type { Chain } from "../../../../chains/types.js"; import type { ThirdwebClient } from "../../../../client/client.js"; +import { + type SocialAuthOption, + socialAuthOptions, +} from "../../../../wallets/types.js"; import type { Account, Wallet } from "../../../interfaces/wallet.js"; import type { EcosystemWalletId, WalletId } from "../../../wallet-types.js"; import type { @@ -35,6 +39,20 @@ export async function connectInAppWallet( | CreateWalletArgs[1], connector: InAppConnector, ): Promise<[Account, Chain]> { + if ( + createOptions?.auth?.mode === "redirect" && + connector.authenticateWithRedirect + ) { + const strategy = options.strategy; + if (!socialAuthOptions.includes(strategy as SocialAuthOption)) { + throw new Error("This authentication method does not support redirects"); + } + connector.authenticateWithRedirect(strategy as SocialAuthOption); + } + // If we don't have authenticateWithRedirect then it's likely react native, so the default is to redirect and we can carry on + // IF WE EVER ADD MORE CONNECTOR TYPES, this could cause redirect to be ignored despite being specified + // TODO: In V6, make everything redirect auth + const authResult = await connector.authenticate(options); const authAccount = authResult.user.account; @@ -66,6 +84,10 @@ export async function autoConnectInAppWallet( | CreateWalletArgs[1], connector: InAppConnector, ): Promise<[Account, Chain]> { + if (options.authResult && connector.loginWithAuthToken) { + await connector.loginWithAuthToken(options.authResult); + } + const user = await getAuthenticatedUser(connector); if (!user) { throw new Error("Failed to authenticate user."); diff --git a/packages/thirdweb/src/wallets/in-app/core/wallet/types.ts b/packages/thirdweb/src/wallets/in-app/core/wallet/types.ts index bc9420c81e4..af54d51ca32 100644 --- a/packages/thirdweb/src/wallets/in-app/core/wallet/types.ts +++ b/packages/thirdweb/src/wallets/in-app/core/wallet/types.ts @@ -3,6 +3,7 @@ import type { ThirdwebClient } from "../../../../client/client.js"; import type { SmartWalletOptions } from "../../../smart/types.js"; import type { AuthOption, SocialAuthOption } from "../../../types.js"; import type { + AuthStoredTokenWithCookieReturnType, MultiStepAuthArgsType, SingleStepAuthArgsType, } from "../authentication/type.js"; @@ -13,10 +14,12 @@ export type InAppWalletConnectionOptions = ( ) & { client: ThirdwebClient; chain?: Chain; + redirect?: boolean; }; export type InAppWalletAutoConnectOptions = { client: ThirdwebClient; + authResult?: AuthStoredTokenWithCookieReturnType; chain?: Chain; }; @@ -27,6 +30,7 @@ export type InAppWalletCreationOptions = | { auth?: { options: InAppWalletAuth[]; + mode?: "popup" | "redirect"; }; metadata?: { image?: { 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 ef904a8caeb..1c1f147553e 100644 --- a/packages/thirdweb/src/wallets/in-app/web/in-app.ts +++ b/packages/thirdweb/src/wallets/in-app/web/in-app.ts @@ -53,6 +53,16 @@ import { createInAppWallet } from "../core/wallet/in-app-core.js"; * hidePrivateKeyExport: true * }); * ``` + * + * Open the Oauth window in the same tab + * ```ts + * import { inAppWallet } from "thirdweb/wallets"; + * const wallet = inAppWallet({ + * auth: { + * mode: "redirect" + * } + * }); + * ``` * @wallet */ export function inAppWallet( 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 b085198e51b..bea07c173a2 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 @@ -1,7 +1,8 @@ import type { ThirdwebClient } from "../../../../../client/client.js"; +import type { OneOf } from "../../../../../utils/type-utils.js"; +import type { SocialAuthOption } from "../../../../../wallets/types.js"; import { type AuthArgsType, - type AuthLoginReturnType, type GetAuthenticatedUserParams, type PreAuthArgsType, UserWalletStatus, @@ -143,8 +144,18 @@ export async function preAuthenticate(args: PreAuthArgsType) { * @wallet */ export async function authenticate( - args: AuthArgsType, -): Promise { + args: OneOf< + | AuthArgsType + | { + strategy: SocialAuthOption; + client: ThirdwebClient; + ecosystem?: Ecosystem; + redirect: boolean; + } + >, +) { const connector = await getInAppWalletConnector(args.client, args.ecosystem); + if (args.redirect && connector.authenticateWithRedirect) + return connector.authenticateWithRedirect(args.strategy); return connector.authenticate(args); } 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 050113d88cb..ee8e6b91c45 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 @@ -40,6 +40,18 @@ export const getSocialAuthLoginPath = ( return baseUrl; }; +export const loginWithOauthRedirect = (options: { + authOption: SocialAuthOption; + client: ThirdwebClient; + ecosystem?: Ecosystem; +}): void => { + const redirectUrl = new URL(window.location.href); + redirectUrl.searchParams.set("walletId", options.ecosystem?.id || "inApp"); + redirectUrl.searchParams.set("authProvider", options.authOption); + const loginUrl = `${getSocialAuthLoginPath(options.authOption, options.client, options.ecosystem)}&redirectUrl=${encodeURIComponent(redirectUrl.toString())}`; + window.location.href = loginUrl; +}; + export const loginWithOauth = async (options: { authOption: SocialAuthOption; client: ThirdwebClient; 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 new file mode 100644 index 00000000000..ddc2c1946ba --- /dev/null +++ b/packages/thirdweb/src/wallets/in-app/web/lib/get-url-token.ts @@ -0,0 +1,36 @@ +import type { AuthOption } from "../../../../wallets/types.js"; +import type { WalletId } from "../../../wallet-types.js"; +import type { AuthStoredTokenWithCookieReturnType } from "../../core/authentication/type.js"; + +/** + * Checks for an auth token and associated metadata in the current URL + */ +export function getUrlToken(): { + walletId?: WalletId; + authResult?: AuthStoredTokenWithCookieReturnType; + authProvider?: AuthOption; +} { + if (!window) { + throw new Error("Attempted to fetch a URL token on the server"); + } + + const queryString = window.location.search; + const params = new URLSearchParams(queryString); + const authResultString = params.get("authResult"); + const walletId = params.get("walletId") as WalletId | undefined; + const authProvider = params.get("authProvider") as AuthOption | undefined; + + if (authResultString && walletId) { + const authResult = JSON.parse(authResultString); + params.delete("authResult"); + params.delete("walletId"); + params.delete("authProvider"); + window.history.pushState( + {}, + "", + `${window.location.pathname}?${params.toString()}`, + ); + return { walletId, authResult, authProvider }; + } + return {}; +} 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 d5ef6d79e0d..99f18eea8d1 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 @@ -1,8 +1,10 @@ import type { ThirdwebClient } from "../../../../client/client.js"; import { getThirdwebBaseUrl } from "../../../../utils/domains.js"; +import type { SocialAuthOption } from "../../../../wallets/types.js"; import type { Account } from "../../../interfaces/wallet.js"; import { type AuthLoginReturnType, + type AuthStoredTokenWithCookieReturnType, type GetUser, type LogoutReturnType, type MultiStepAuthArgsType, @@ -15,7 +17,7 @@ import type { InAppConnector } from "../../core/interfaces/connector.js"; import type { InAppWalletConstructorType } from "../types.js"; import { InAppWalletIframeCommunicator } from "../utils/iFrameCommunication/InAppWalletIframeCommunicator.js"; import { Auth, type AuthQuerierTypes } from "./auth/iframe-auth.js"; -import { loginWithOauth } from "./auth/oauth.js"; +import { loginWithOauth, loginWithOauthRedirect } from "./auth/oauth.js"; import { loginWithPasskey, registerPasskey } from "./auth/passkeys.js"; import { IFrameWallet } from "./in-app-account.js"; @@ -151,6 +153,18 @@ export class InAppWebConnector implements InAppConnector { } } + authenticateWithRedirect(strategy: SocialAuthOption): void { + loginWithOauthRedirect({ + authOption: strategy, + client: this.wallet.client, + ecosystem: this.wallet.ecosystem, + }); + } + + async loginWithAuthToken(authResult: AuthStoredTokenWithCookieReturnType) { + return this.auth.loginWithAuthToken(authResult); + } + async authenticate( args: MultiStepAuthArgsType | SingleStepAuthArgsType, ): Promise { @@ -196,14 +210,14 @@ export class InAppWebConnector implements InAppConnector { authenticatorType: args.authenticatorType, username: args.passkeyName, }); - return this.auth.loginWithAuthToken(authToken); + return this.loginWithAuthToken(authToken); } const authToken = await loginWithPasskey({ client: this.wallet.client, ecosystem: this.wallet.ecosystem, authenticatorType: args.authenticatorType, }); - return this.auth.loginWithAuthToken(authToken); + return this.loginWithAuthToken(authToken); } case "apple": case "facebook": @@ -216,7 +230,7 @@ export class InAppWebConnector implements InAppConnector { closeOpenedWindow: args.closeOpenedWindow, openedWindow: args.openedWindow, }); - return this.auth.loginWithAuthToken(authToken); + return this.loginWithAuthToken(authToken); } default: