diff --git a/.changeset/hot-donkeys-itch.md b/.changeset/hot-donkeys-itch.md new file mode 100644 index 00000000000..139c0866a86 --- /dev/null +++ b/.changeset/hot-donkeys-itch.md @@ -0,0 +1,6 @@ +--- +"thirdweb": minor +--- + +Strip URL scheme from SIWE message domain field for EIP-4361 compliance + diff --git a/packages/thirdweb/src/auth/core/generate-login-payload.test.ts b/packages/thirdweb/src/auth/core/generate-login-payload.test.ts index a3e98d48475..0d781304f3b 100644 --- a/packages/thirdweb/src/auth/core/generate-login-payload.test.ts +++ b/packages/thirdweb/src/auth/core/generate-login-payload.test.ts @@ -89,9 +89,35 @@ describe("generateLoginPayload", () => { "issued_at": "1970-01-01T00:00:00.000Z", "resources": undefined, "statement": "Please ensure that the domain above matches the URL of the current website.", - "uri": "example.com", + "uri": "https://example.com", "version": "1", } `); }); + + test("should strip URL scheme from domain", async () => { + const options = { + client: TEST_CLIENT, + domain: "https://example.com", + login: { + nonce: { + generate() { + return "20cd4ddb-6857-4d36-8e44-9f6e026b8de9"; + }, + validate(uuid: string) { + return uuid === "20cd4ddb-6857-4d36-8e44-9f6e026b8de9"; + }, + }, + }, + }; + + const generatePayload = generateLoginPayload(options); + const result = await generatePayload({ + address: "0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B", + chainId: 1, + }); + + expect(result.domain).toBe("example.com"); + expect(result.uri).toBe("https://example.com"); + }); }); diff --git a/packages/thirdweb/src/auth/core/generate-login-payload.ts b/packages/thirdweb/src/auth/core/generate-login-payload.ts index 5debd48a04a..2de336bcf5a 100644 --- a/packages/thirdweb/src/auth/core/generate-login-payload.ts +++ b/packages/thirdweb/src/auth/core/generate-login-payload.ts @@ -3,6 +3,7 @@ import { DEFAULT_LOGIN_STATEMENT, DEFAULT_LOGIN_VERSION, } from "./constants.js"; +import { stripUrlScheme } from "./strip-url-scheme.js"; import type { AuthOptions, LoginPayload } from "./types.js"; /** @@ -31,7 +32,7 @@ export function generateLoginPayload(options: AuthOptions) { return { address, chain_id: chainId ? chainId.toString() : undefined, - domain: options.domain, + domain: stripUrlScheme(options.domain), expiration_time: new Date(now + expirationTime).toISOString(), invalid_before: new Date(now - expirationTime).toISOString(), issued_at: new Date(now).toISOString(), @@ -45,7 +46,7 @@ export function generateLoginPayload(options: AuthOptions) { )(), resources: options.login?.resources, statement: options.login?.statement || DEFAULT_LOGIN_STATEMENT, - uri: options.login?.uri || options.domain, + uri: options.login?.uri || `https://${stripUrlScheme(options.domain)}`, version: options.login?.version || DEFAULT_LOGIN_VERSION, }; }; diff --git a/packages/thirdweb/src/auth/core/strip-url-scheme.test.ts b/packages/thirdweb/src/auth/core/strip-url-scheme.test.ts new file mode 100644 index 00000000000..dff002b48a3 --- /dev/null +++ b/packages/thirdweb/src/auth/core/strip-url-scheme.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "vitest"; +import { stripUrlScheme } from "./strip-url-scheme.js"; + +describe("stripUrlScheme", () => { + test("should strip https scheme", () => { + expect(stripUrlScheme("https://example.com")).toBe("example.com"); + }); + + test("should strip http scheme", () => { + expect(stripUrlScheme("http://example.com")).toBe("example.com"); + }); + + test("should leave bare domains unchanged", () => { + expect(stripUrlScheme("example.com")).toBe("example.com"); + }); + + test("should strip trailing slash", () => { + expect(stripUrlScheme("https://example.com/")).toBe("example.com"); + }); + + test("should strip trailing path", () => { + expect(stripUrlScheme("https://example.com/path/to/resource")).toBe( + "example.com", + ); + }); + + test("should preserve port", () => { + expect(stripUrlScheme("https://localhost:3000")).toBe("localhost:3000"); + }); + + test("should strip trailing slash from bare domain", () => { + expect(stripUrlScheme("example.com/")).toBe("example.com"); + }); + + test("should strip query string", () => { + expect(stripUrlScheme("example.com?x=1")).toBe("example.com"); + }); + + test("should strip fragment", () => { + expect(stripUrlScheme("example.com#frag")).toBe("example.com"); + }); + + test("should strip query string with scheme", () => { + expect(stripUrlScheme("https://example.com?x=1")).toBe("example.com"); + }); +}); diff --git a/packages/thirdweb/src/auth/core/strip-url-scheme.ts b/packages/thirdweb/src/auth/core/strip-url-scheme.ts new file mode 100644 index 00000000000..0adcc653dd0 --- /dev/null +++ b/packages/thirdweb/src/auth/core/strip-url-scheme.ts @@ -0,0 +1,8 @@ +/** + * Strips the URL scheme (e.g. "https://") and any trailing path/slash from a domain string. + * Per EIP-4361, the domain field should be an RFC 3986 authority (host without scheme). + * @internal + */ +export function stripUrlScheme(domain: string): string { + return domain.replace(/^https?:\/\//, "").replace(/[/?#].*$/, ""); +} diff --git a/packages/thirdweb/src/auth/core/verify-login-payload.test.ts b/packages/thirdweb/src/auth/core/verify-login-payload.test.ts index 429f0fd05a3..f4fe1bb4e06 100644 --- a/packages/thirdweb/src/auth/core/verify-login-payload.test.ts +++ b/packages/thirdweb/src/auth/core/verify-login-payload.test.ts @@ -4,6 +4,7 @@ import { TEST_ACCOUNT_A, TEST_ACCOUNT_B, } from "../../../test/src/test-wallets.js"; +import { createLoginMessage } from "./create-login-message.js"; import { generateLoginPayload } from "./generate-login-payload.js"; import { signLoginPayload } from "./sign-login-payload.js"; import { verifyLoginPayload } from "./verify-login-payload.js"; @@ -145,4 +146,97 @@ describe("verifyLoginPayload", () => { expect(verificationResult.valid).toBe(false); }); + + test("should work when domain has URL scheme (backward compat)", async () => { + const options = { + client: TEST_CLIENT, + domain: "https://example.com", + login: { + nonce: { + generate() { + return "20cd4ddb-6857-4d36-8e44-9f6e026b8de9"; + }, + validate(uuid: string) { + return uuid === "20cd4ddb-6857-4d36-8e44-9f6e026b8de9"; + }, + }, + payloadExpirationTimeSeconds: 3600, + statement: "This is a statement", + }, + }; + + const generatePayload = generateLoginPayload(options); + const payloadToSign = await generatePayload({ + address: TEST_ACCOUNT_A.address, + }); + + // sign the payload + const signatureResult = await signLoginPayload({ + account: TEST_ACCOUNT_A, + payload: payloadToSign, + }); + + // verify the payload + const verifyPayload = verifyLoginPayload(options); + + const verificationResult = await verifyPayload(signatureResult); + + expect(verificationResult.valid).toBe(true); + if (verificationResult.valid) { + expect(verificationResult.payload.address).toBe(TEST_ACCOUNT_A.address); + // domain in payload should have scheme stripped + expect(verificationResult.payload.domain).toBe("example.com"); + } + }); + + test("should verify legacy payloads with scheme in domain", async () => { + // Simulate a legacy payload where the old SDK did NOT strip the scheme + const legacyPayload = { + address: TEST_ACCOUNT_A.address, + domain: "https://example.com", + expiration_time: new Date(3600000).toISOString(), + invalid_before: new Date(-3600000).toISOString(), + issued_at: new Date(0).toISOString(), + nonce: "20cd4ddb-6857-4d36-8e44-9f6e026b8de9", + statement: "This is a statement", + uri: "https://example.com", + version: "1", + }; + + // createLoginMessage now uses domain as-is, so the signed message + // will contain the scheme — matching what the old SDK would have produced + const legacyMessage = createLoginMessage(legacyPayload); + expect(legacyMessage).toContain("https://example.com wants you to sign in"); + + const signature = await TEST_ACCOUNT_A.signMessage({ + message: legacyMessage, + }); + + // Verify with options whose domain also has scheme + const verifyPayload = verifyLoginPayload({ + client: TEST_CLIENT, + domain: "https://example.com", + login: { + nonce: { + generate() { + return "20cd4ddb-6857-4d36-8e44-9f6e026b8de9"; + }, + validate(uuid: string) { + return uuid === "20cd4ddb-6857-4d36-8e44-9f6e026b8de9"; + }, + }, + payloadExpirationTimeSeconds: 3600, + statement: "This is a statement", + uri: "https://example.com", + version: "1", + }, + }); + + const verificationResult = await verifyPayload({ + payload: legacyPayload, + signature, + }); + + expect(verificationResult.valid).toBe(true); + }); }); diff --git a/packages/thirdweb/src/auth/core/verify-login-payload.ts b/packages/thirdweb/src/auth/core/verify-login-payload.ts index 6eaac5b5880..609fbc2fd28 100644 --- a/packages/thirdweb/src/auth/core/verify-login-payload.ts +++ b/packages/thirdweb/src/auth/core/verify-login-payload.ts @@ -3,6 +3,7 @@ import { getCachedChain } from "../../chains/utils.js"; import { verifySignature } from "../verify-signature.js"; import { DEFAULT_LOGIN_STATEMENT, DEFAULT_LOGIN_VERSION } from "./constants.js"; import { createLoginMessage } from "./create-login-message.js"; +import { stripUrlScheme } from "./strip-url-scheme.js"; import type { AuthOptions, LoginPayload } from "./types.js"; /** @@ -48,7 +49,8 @@ export function verifyLoginPayload(options: AuthOptions) { signature, }: VerifyLoginPayloadParams): Promise => { // check that the intended domain matches the domain of the payload - if (payload.domain !== options.domain) { + // normalize both sides by stripping URL scheme for backward compatibility + if (stripUrlScheme(payload.domain) !== stripUrlScheme(options.domain)) { return { error: `Expected domain '${options.domain}' does not match domain on payload '${payload.domain}'`, valid: false, @@ -128,19 +130,38 @@ export function verifyLoginPayload(options: AuthOptions) { } } - // this is the message the user should have signed (resulting in the singature passd) - const computedMessage = createLoginMessage(payload); + // Build message with normalized (scheme-stripped) domain for EIP-4361 compliance + const normalizedDomain = stripUrlScheme(payload.domain); + const normalizedPayload = + normalizedDomain !== payload.domain + ? { ...payload, domain: normalizedDomain } + : payload; + const computedMessage = createLoginMessage(normalizedPayload); - const signatureIsValid = await verifySignature({ + const verifyOpts = { address: payload.address, chain: payload.chain_id ? getCachedChain(Number.parseInt(payload.chain_id)) : undefined, client: options.client, - message: computedMessage, signature: signature, + }; + + let signatureIsValid = await verifySignature({ + ...verifyOpts, + message: computedMessage, }); + // If normalized message failed and domain contained a scheme, try the legacy + // message for backward compatibility with signatures from older SDK versions + if (!signatureIsValid && normalizedDomain !== payload.domain) { + const legacyMessage = createLoginMessage(payload); + signatureIsValid = await verifySignature({ + ...verifyOpts, + message: legacyMessage, + }); + } + if (!signatureIsValid) { return { error: "Invalid signature",