From 545b6562bed6f8021bab2f57829fd80af9441ac2 Mon Sep 17 00:00:00 2001 From: 0xFirekeeper <0xFirekeeper@gmail.com> Date: Thu, 13 Aug 2026 23:45:14 +0700 Subject: [PATCH 1/7] [SDK] Verify redirect state before AutoConnect consumes URL auth material Redirect-based in-app wallet login now mints a one-time `state` value bound to the browser, echoes it on the redirect URL, and AutoConnect only trusts a returned `authResult` when that state matches. This mirrors the origin check the popup login flow already performs. Also adds a `readUrlToken` option to AutoConnect / useAutoConnect to disable reading auth material from the URL. Co-Authored-By: Claude Opus 4.8 --- .changeset/autoconnect-redirect-state.md | 5 ++ .../connection/autoConnectCore.test.ts | 37 ++++++++++++ .../src/wallets/connection/autoConnectCore.ts | 15 ++++- .../thirdweb/src/wallets/connection/types.ts | 14 +++++ .../core/authentication/getLoginPath.ts | 9 +++ .../src/wallets/in-app/web/lib/auth/oauth.ts | 6 ++ .../web/lib/auth/redirect-state.test.tsx | 41 ++++++++++++++ .../in-app/web/lib/auth/redirect-state.ts | 56 +++++++++++++++++++ .../in-app/web/lib/get-url-token.test.tsx | 23 ++++++++ .../wallets/in-app/web/lib/get-url-token.ts | 6 +- 10 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 .changeset/autoconnect-redirect-state.md create mode 100644 packages/thirdweb/src/wallets/in-app/web/lib/auth/redirect-state.test.tsx create mode 100644 packages/thirdweb/src/wallets/in-app/web/lib/auth/redirect-state.ts diff --git a/.changeset/autoconnect-redirect-state.md b/.changeset/autoconnect-redirect-state.md new file mode 100644 index 00000000000..d254bc18309 --- /dev/null +++ b/.changeset/autoconnect-redirect-state.md @@ -0,0 +1,5 @@ +--- +"thirdweb": minor +--- + +Redirect-based in-app wallet logins now include and verify a one-time `state` value before `AutoConnect` consumes auth material returned in the URL, tying the returned token back to a flow the page actually started. Added a `readUrlToken` option to `AutoConnect` / `useAutoConnect` to opt out of reading wallet auth material from the URL entirely. diff --git a/packages/thirdweb/src/wallets/connection/autoConnectCore.test.ts b/packages/thirdweb/src/wallets/connection/autoConnectCore.test.ts index b56ff1665ae..ef38153dec1 100644 --- a/packages/thirdweb/src/wallets/connection/autoConnectCore.test.ts +++ b/packages/thirdweb/src/wallets/connection/autoConnectCore.test.ts @@ -6,6 +6,7 @@ import { TEST_CLIENT } from "~test/test-clients.js"; import { TEST_ACCOUNT_A } from "~test/test-wallets.js"; import { createWalletAdapter } from "../../adapters/wallet-adapter.js"; import { ethereum } from "../../chains/chain-definitions/ethereum.js"; +import type { AuthStoredTokenWithCookieReturnType } from "../in-app/core/authentication/types.js"; import { AUTH_TOKEN_LOCAL_STORAGE_NAME } from "../in-app/core/constants/settings.js"; import { getUrlToken } from "../in-app/web/lib/get-url-token.js"; import type { Wallet } from "../interfaces/wallet.js"; @@ -138,6 +139,42 @@ describe("useAutoConnectCore", () => { expect(storedCookie).toBe(mockAuthCookie); }); + it("should ignore a URL authResult with no matching redirect state", async () => { + const wallet = createWalletAdapter({ + adaptedAccount: TEST_ACCOUNT_A, + chain: ethereum, + client: TEST_CLIENT, + onDisconnect: () => {}, + switchChain: () => {}, + }); + // A crafted URL supplies an authResult (and a cookie) with no state to back it. + // Because no redirect state was stored, the token must be rejected wholesale and + // the attacker-supplied cookie must NOT be persisted. + vi.mocked(getUrlToken).mockReturnValue({ + authCookie: "should-not-be-saved", + authResult: { + storedToken: { cookieString: "attacker-token" }, + } as unknown as AuthStoredTokenWithCookieReturnType, + walletId: wallet.id, + }); + + await autoConnectCore({ + createWalletFn: () => wallet, + force: true, + manager, + props: { + client: TEST_CLIENT, + wallets: [wallet], + }, + storage: mockStorage, + }); + + const storedCookie = await mockStorage.getItem( + AUTH_TOKEN_LOCAL_STORAGE_NAME(TEST_CLIENT.clientId), + ); + expect(storedCookie).not.toBe("should-not-be-saved"); + }); + it("should handle error when manager connection fails", async () => { const wallet1 = createWalletAdapter({ adaptedAccount: TEST_ACCOUNT_A, diff --git a/packages/thirdweb/src/wallets/connection/autoConnectCore.ts b/packages/thirdweb/src/wallets/connection/autoConnectCore.ts index a08776a08f4..0a139caa11f 100644 --- a/packages/thirdweb/src/wallets/connection/autoConnectCore.ts +++ b/packages/thirdweb/src/wallets/connection/autoConnectCore.ts @@ -10,6 +10,7 @@ import type { AuthStoredTokenWithCookieReturnType, } from "../in-app/core/authentication/types.js"; import { isInAppSigner } from "../in-app/core/wallet/is-in-app-signer.js"; +import { consumeRedirectState } from "../in-app/web/lib/auth/redirect-state.js"; import { getUrlToken } from "../in-app/web/lib/get-url-token.js"; import type { Wallet } from "../interfaces/wallet.js"; import { @@ -82,7 +83,19 @@ const _autoConnectCore = async ({ getStoredActiveWalletId(storage), ]); - const urlToken = getUrlToken(); + const rawUrlToken = props.readUrlToken === false ? undefined : getUrlToken(); + + // A token carrying an authResult only ever comes from an SDK-initiated redirect + // login, which persists a one-time state value. Require that state to match before + // trusting the URL-provided auth material, mirroring the origin check the popup + // login flow already performs. If it does not match, ignore the token entirely. + let urlToken = rawUrlToken; + if (rawUrlToken?.authResult) { + const validState = await consumeRedirectState(rawUrlToken.state); + if (!validState) { + urlToken = undefined; + } + } // Handle linking flow: autoconnect with stored credentials, then link the new profile if (urlToken?.authFlow === "link" && urlToken.authResult) { diff --git a/packages/thirdweb/src/wallets/connection/types.ts b/packages/thirdweb/src/wallets/connection/types.ts index f77537db9e3..b682f3d5f2a 100644 --- a/packages/thirdweb/src/wallets/connection/types.ts +++ b/packages/thirdweb/src/wallets/connection/types.ts @@ -121,6 +121,20 @@ export type AutoConnectProps = { */ onTimeout?: () => void; + /** + * Whether to read wallet auth material (such as an auth token or cookie) from the + * current page URL when auto-connecting. + * + * The redirect-based in-app wallet login and the `SiteLink` / `SiteEmbed` components + * pass auth material via URL parameters, which `AutoConnect` reads to restore the + * session. Set this to `false` to disable reading auth material from the URL entirely + * — useful if your app only uses popup, OTP, or passkey login and never hands off a + * session between sites. + * + * @default true + */ + readUrlToken?: boolean; + /** * @hidden */ 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 d259821643d..2caa7fbd539 100644 --- a/packages/thirdweb/src/wallets/in-app/core/authentication/getLoginPath.ts +++ b/packages/thirdweb/src/wallets/in-app/core/authentication/getLoginPath.ts @@ -22,6 +22,7 @@ export const getLoginUrl = ({ mode = "popup", redirectUrl, authFlow, + state, }: { authOption: AuthOption; client: ThirdwebClient; @@ -29,6 +30,11 @@ export const getLoginUrl = ({ mode?: "popup" | "redirect" | "window"; redirectUrl?: string; authFlow?: "connect" | "link"; + /** + * One-time value tied to the browser session that started this flow. It is + * echoed back on the redirect and validated before the returned token is trusted. + */ + state?: string; }) => { if (mode === "popup" && redirectUrl) { throw new Error("Redirect URL is not supported for popup mode"); @@ -54,6 +60,9 @@ export const getLoginUrl = ({ if (authFlow) { formattedRedirectUrl.searchParams.set("authFlow", authFlow); } + if (state) { + formattedRedirectUrl.searchParams.set("state", state); + } baseUrl = `${baseUrl}&redirectUrl=${encodeURIComponent(formattedRedirectUrl.toString())}`; } 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 dd13dd1308f..8d7593e13ff 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 @@ -5,6 +5,7 @@ import { getLoginUrl } from "../../../core/authentication/getLoginPath.js"; import type { AuthStoredTokenWithCookieReturnType } from "../../../core/authentication/types.js"; import type { Ecosystem } from "../../../core/wallet/types.js"; import { DEFAULT_POP_UP_SIZE } from "./constants.js"; +import { storeRedirectState } from "./redirect-state.js"; const closeWindow = ({ isWindowOpenedByFn, @@ -34,10 +35,15 @@ export async function loginWithOauthRedirect(options: { mode?: "redirect" | "popup" | "window"; authFlow?: "connect" | "link"; }): Promise { + // Persist a one-time state bound to this browser and echo it on the redirect so + // the returned auth token can be tied back to a flow this page actually started. + const state = + options.mode === "popup" ? undefined : await storeRedirectState(); const loginUrl = getLoginUrl({ ...options, mode: options.mode || "redirect", authFlow: options.authFlow, + state, }); if (options.mode === "redirect") { window.location.href = loginUrl; diff --git a/packages/thirdweb/src/wallets/in-app/web/lib/auth/redirect-state.test.tsx b/packages/thirdweb/src/wallets/in-app/web/lib/auth/redirect-state.test.tsx new file mode 100644 index 00000000000..67a48cc5212 --- /dev/null +++ b/packages/thirdweb/src/wallets/in-app/web/lib/auth/redirect-state.test.tsx @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { consumeRedirectState, storeRedirectState } from "./redirect-state.js"; + +describe.runIf(typeof window !== "undefined")("redirect-state", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it("accepts the exact state it stored", async () => { + const state = await storeRedirectState(); + expect(await consumeRedirectState(state)).toBe(true); + }); + + it("rejects a mismatched state", async () => { + await storeRedirectState(); + expect(await consumeRedirectState("not-the-stored-state")).toBe(false); + }); + + it("rejects an undefined returned state", async () => { + await storeRedirectState(); + expect(await consumeRedirectState(undefined)).toBe(false); + }); + + it("rejects when nothing was stored", async () => { + expect(await consumeRedirectState("anything")).toBe(false); + }); + + it("is single-use: a valid state cannot be replayed", async () => { + const state = await storeRedirectState(); + expect(await consumeRedirectState(state)).toBe(true); + expect(await consumeRedirectState(state)).toBe(false); + }); + + it("clears the stored value even on a mismatch", async () => { + const state = await storeRedirectState(); + // a wrong attempt still consumes the stored value... + expect(await consumeRedirectState("wrong")).toBe(false); + // ...so the real value can no longer be used either + expect(await consumeRedirectState(state)).toBe(false); + }); +}); diff --git a/packages/thirdweb/src/wallets/in-app/web/lib/auth/redirect-state.ts b/packages/thirdweb/src/wallets/in-app/web/lib/auth/redirect-state.ts new file mode 100644 index 00000000000..95e54482835 --- /dev/null +++ b/packages/thirdweb/src/wallets/in-app/web/lib/auth/redirect-state.ts @@ -0,0 +1,56 @@ +import { randomBytesHex } from "../../../../../utils/random.js"; +import { webLocalStorage } from "../../../../../utils/storage/webStorage.js"; + +const REDIRECT_STATE_STORAGE_KEY = "thirdweb:auth-redirect-state"; +const REDIRECT_STATE_TTL_MS = 10 * 60 * 1000; // 10 minutes + +/** + * Creates a one-time state value bound to this browser, persists it, and returns + * it so it can be sent as the `state` parameter when starting a redirect auth flow. + * @internal + */ +export async function storeRedirectState(): Promise { + const state = randomBytesHex(16); + await webLocalStorage.setItem( + REDIRECT_STATE_STORAGE_KEY, + JSON.stringify({ expiresAt: Date.now() + REDIRECT_STATE_TTL_MS, state }), + ); + return state; +} + +/** + * Validates the `state` returned from a redirect auth flow against the one-time + * value stored when the flow started, then clears it so it cannot be reused. + * Returns `false` when nothing was stored, the value is expired, or it does not match. + * @internal + */ +export async function consumeRedirectState( + returnedState: string | undefined, +): Promise { + const stored = await webLocalStorage.getItem(REDIRECT_STATE_STORAGE_KEY); + // one-time use: always clear the stored value, even on a mismatch + await webLocalStorage.removeItem(REDIRECT_STATE_STORAGE_KEY); + + if (!stored || !returnedState) { + return false; + } + + try { + const parsed = JSON.parse(stored) as { + state?: unknown; + expiresAt?: unknown; + }; + if ( + typeof parsed.state !== "string" || + typeof parsed.expiresAt !== "number" + ) { + return false; + } + if (Date.now() > parsed.expiresAt) { + return false; + } + return parsed.state === returnedState; + } catch { + return false; + } +} 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 194b35f2fce..cdf9b92404e 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 @@ -56,6 +56,29 @@ describe.runIf(global.window !== undefined)("getUrlToken", () => { }); }); + it("should parse the state param and strip it from the URL", () => { + Object.defineProperty(window, "location", { + value: { + ...originalLocation, + hash: "", + pathname: "/", + search: "?walletId=123&authResult=%7B%22t%22%3A1%7D&state=abc123", + }, + writable: true, + }); + + const pushStateSpy = vi.spyOn(window.history, "pushState"); + + const result = getUrlToken(); + + expect(result?.authResult).toEqual({ t: 1 }); + expect(result?.state).toBe("abc123"); + + // state must be stripped from the URL alongside the other auth params + expect(pushStateSpy).toHaveBeenCalledWith({}, "", "/"); + pushStateSpy.mockRestore(); + }); + it("should handle authCookie and update URL correctly", () => { window.location.search = "?walletId=123&authCookie=myCookie"; 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 ef28ffcd2c2..df7d89f7fcd 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 @@ -12,6 +12,7 @@ export function getUrlToken(): authProvider?: AuthOption; authCookie?: string; authFlow?: "connect" | "link"; + state?: string; } | undefined { if (typeof document === "undefined") { @@ -47,6 +48,7 @@ export function getUrlToken(): const authFlow = (params.get("authFlow") ?? hashParams?.get("authFlow") ?? undefined) as "connect" | "link" | undefined; + const state = params.get("state") ?? hashParams?.get("state") ?? undefined; if ((authCookie || authResultString) && walletId) { const authResult = (() => { @@ -60,10 +62,12 @@ export function getUrlToken(): params.delete("authProvider"); params.delete("authCookie"); params.delete("authFlow"); + params.delete("state"); hashParams?.delete("walletId"); hashParams?.delete("authProvider"); hashParams?.delete("authCookie"); hashParams?.delete("authFlow"); + hashParams?.delete("state"); const remainingSearch = params.toString(); const searchString = remainingSearch ? `?${remainingSearch}` : ""; @@ -82,7 +86,7 @@ export function getUrlToken(): "", `${window.location.pathname}${searchString}${hashString}`, ); - return { authCookie, authFlow, authProvider, authResult, walletId }; + return { authCookie, authFlow, authProvider, authResult, state, walletId }; } return undefined; } From f6d63894093a678931a882b28606a76bb0e8184f Mon Sep 17 00:00:00 2001 From: 0xFirekeeper <0xFirekeeper@gmail.com> Date: Thu, 13 Aug 2026 23:59:25 +0700 Subject: [PATCH 2/7] [Dashboard] Fix biome format drift in rotate-admin-key button Pre-existing unformatted line surfaced by CI once the dashboard lint cache was invalidated. Formatting-only, no behavior change. Co-Authored-By: Claude Opus 4.8 --- .../(sidebar)/vault/components/rotate-admin-key.client.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/components/rotate-admin-key.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/components/rotate-admin-key.client.tsx index 08ccda9f755..b0113371090 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/components/rotate-admin-key.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/components/rotate-admin-key.client.tsx @@ -326,7 +326,9 @@ export default function RotateAdminKeyButton(props: { Cancel