Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
6 changes: 6 additions & 0 deletions .changeset/hot-donkeys-itch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"thirdweb": minor
---

Strip URL scheme from SIWE message domain field for EIP-4361 compliance

28 changes: 27 additions & 1 deletion packages/thirdweb/src/auth/core/generate-login-payload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
5 changes: 3 additions & 2 deletions packages/thirdweb/src/auth/core/generate-login-payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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(),
Expand All @@ -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,
};
};
Expand Down
46 changes: 46 additions & 0 deletions packages/thirdweb/src/auth/core/strip-url-scheme.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
8 changes: 8 additions & 0 deletions packages/thirdweb/src/auth/core/strip-url-scheme.ts
Original file line number Diff line number Diff line change
@@ -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(/[/?#].*$/, "");
}
94 changes: 94 additions & 0 deletions packages/thirdweb/src/auth/core/verify-login-payload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
});
});
31 changes: 26 additions & 5 deletions packages/thirdweb/src/auth/core/verify-login-payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -48,7 +49,8 @@ export function verifyLoginPayload(options: AuthOptions) {
signature,
}: VerifyLoginPayloadParams): Promise<VerifyLoginPayloadResult> => {
// 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,
Expand Down Expand Up @@ -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",
Expand Down
Loading