From e1aaf4669d6613774b893bbc07214b8d08985e7e Mon Sep 17 00:00:00 2001 From: Ganesh Suresh Patra Date: Thu, 10 Sep 2026 17:23:27 +0530 Subject: [PATCH 1/6] feat: add Segment analytics for login and MFA success rates Track Core Kit init, login, factor, and MFA outcomes in the same Segment/Mixpanel source as web3auth-web so we can measure success vs failure per client id. Co-authored-by: Cursor --- package-lock.json | 252 ++++++++++++-- package.json | 6 +- scripts/sync-sdk-version.mjs | 12 + src/analytics.ts | 167 +++++++++ src/index.ts | 1 + src/interfaces.ts | 10 + src/mpcCoreKit.ts | 632 +++++++++++++++++++++++++---------- src/sdkVersion.ts | 2 + tests/analytics.spec.ts | 179 ++++++++++ 9 files changed, 1047 insertions(+), 214 deletions(-) create mode 100644 scripts/sync-sdk-version.mjs create mode 100644 src/analytics.ts create mode 100644 src/sdkVersion.ts create mode 100644 tests/analytics.spec.ts diff --git a/package-lock.json b/package-lock.json index 5761ec7d..d45d79d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "3.6.0", "license": "ISC", "dependencies": { + "@segment/analytics-next": "^1.84.0", "@tkey/common-types": "^15.1.0", "@tkey/core": "^15.1.0", "@tkey/share-serialization": "^15.1.0", @@ -41,7 +42,7 @@ "@types/chai": "^4.3.16", "@types/elliptic": "^6.4.18", "@types/jsonwebtoken": "^9.0.7", - "@types/node": "^20.14.0", + "@types/node": "^22.20.2", "@typescript-eslint/parser": "^7.18.0", "chai": "^5.1.1", "cross-env": "^7.0.3", @@ -108,6 +109,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1699,6 +1701,7 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -1804,7 +1807,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=18" } @@ -1822,7 +1824,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -1840,7 +1841,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -1858,7 +1858,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -1876,7 +1875,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -1894,7 +1892,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -1912,7 +1909,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1930,7 +1926,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1948,7 +1943,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1966,7 +1960,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1984,7 +1977,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2002,7 +1994,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2020,7 +2011,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2038,7 +2028,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2056,7 +2045,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2074,7 +2062,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2092,7 +2079,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2110,7 +2096,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2128,7 +2113,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2146,7 +2130,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2164,7 +2147,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2182,7 +2164,6 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": ">=18" } @@ -2200,7 +2181,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=18" } @@ -2218,7 +2198,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -2236,7 +2215,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -2254,7 +2232,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -2576,6 +2553,27 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lukeed/csprng": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", + "integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@lukeed/uuid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@lukeed/uuid/-/uuid-2.0.1.tgz", + "integrity": "sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w==", + "license": "MIT", + "dependencies": { + "@lukeed/csprng": "^1.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@microsoft/tsdoc": { "version": "0.15.0", "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.0.tgz", @@ -2720,6 +2718,7 @@ "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -3407,6 +3406,117 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@segment/analytics-core": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/@segment/analytics-core/-/analytics-core-1.8.3.tgz", + "integrity": "sha512-63X8e2DWb2ZnJ9S3H8iSOFnbeDXMkTGhA8kZG4eFAO07QBwBVyWo5BCpv6vmuOMEkv2le6t2FMg1p6ZeJQA/4A==", + "license": "MIT", + "dependencies": { + "@lukeed/uuid": "^2.0.0", + "@segment/analytics-generic-utils": "1.2.0", + "dset": "^3.1.4", + "tslib": "^2.4.1" + } + }, + "node_modules/@segment/analytics-generic-utils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@segment/analytics-generic-utils/-/analytics-generic-utils-1.2.0.tgz", + "integrity": "sha512-DfnW6mW3YQOLlDQQdR89k4EqfHb0g/3XvBXkovH1FstUN93eL1kfW9CsDcVQyH3bAC5ZsFyjA/o/1Q2j0QeoWw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.1" + } + }, + "node_modules/@segment/analytics-next": { + "version": "1.84.1", + "resolved": "https://registry.npmjs.org/@segment/analytics-next/-/analytics-next-1.84.1.tgz", + "integrity": "sha512-jcwALgEc4DP6PG6oj0YS0yLiQL2R/N1NAgGGH50898/FNcXLyv/v8c+qoiHJxIpGm3qp8nJZTDeGe1z1Co2NzA==", + "license": "MIT", + "dependencies": { + "@lukeed/uuid": "^2.0.0", + "@segment/analytics-core": "1.8.3", + "@segment/analytics-generic-utils": "1.2.0", + "@segment/analytics-page-tools": "1.0.0", + "@segment/analytics.js-video-plugins": "^0.2.1", + "@segment/facade": "^3.4.9", + "dset": "^3.1.4", + "js-cookie": "^3.0.7", + "node-fetch": "^2.6.7", + "tslib": "^2.4.1", + "unfetch": "^4.1.0" + } + }, + "node_modules/@segment/analytics-next/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/@segment/analytics-page-tools": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@segment/analytics-page-tools/-/analytics-page-tools-1.0.0.tgz", + "integrity": "sha512-o9OVB91qLB9qb0Bw1HfjmWm5AnrMNULRjx++4lBqLt8InKRX1urrRBparVlpj+yJA0sckN5ZcsfazRLuPgBYDQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.1" + } + }, + "node_modules/@segment/analytics.js-video-plugins": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@segment/analytics.js-video-plugins/-/analytics.js-video-plugins-0.2.1.tgz", + "integrity": "sha512-lZwCyEXT4aaHBLNK433okEKdxGAuyrVmop4BpQqQSJuRz0DglPZgd9B/XjiiWs1UyOankg2aNYMN3VcS8t4eSQ==", + "license": "ISC", + "dependencies": { + "unfetch": "^3.1.1" + } + }, + "node_modules/@segment/analytics.js-video-plugins/node_modules/unfetch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/unfetch/-/unfetch-3.1.2.tgz", + "integrity": "sha512-L0qrK7ZeAudGiKYw6nzFjnJ2D5WHblUBwmHIqtPS6oKUd+Hcpk7/hKsSmcHsTlpd1TbTNsiRBUKRq3bHLNIqIw==", + "license": "MIT" + }, + "node_modules/@segment/facade": { + "version": "3.4.10", + "resolved": "https://registry.npmjs.org/@segment/facade/-/facade-3.4.10.tgz", + "integrity": "sha512-xVQBbB/lNvk/u8+ey0kC/+g8pT3l0gCT8O2y9Z+StMMn3KAFAQ9w8xfgef67tJybktOKKU7pQGRPolRM1i1pdA==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@segment/isodate-traverse": "^1.1.1", + "inherits": "^2.0.4", + "new-date": "^1.0.3", + "obj-case": "0.2.1" + } + }, + "node_modules/@segment/isodate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@segment/isodate/-/isodate-1.0.3.tgz", + "integrity": "sha512-BtanDuvJqnACFkeeYje7pWULVv8RgZaqKHWwGFnL/g/TH/CcZjkIVTfGDp/MAxmilYHUkrX70SqwnYSTNEaN7A==", + "license": "SEE LICENSE IN LICENSE" + }, + "node_modules/@segment/isodate-traverse": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@segment/isodate-traverse/-/isodate-traverse-1.1.1.tgz", + "integrity": "sha512-+G6e1SgAUkcq0EDMi+SRLfT48TNlLPF3QnSgFGVs0V9F3o3fq/woQ2rHFlW20W0yy5NnCUH0QGU3Am2rZy/E3w==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@segment/isodate": "^1.0.3" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -3734,6 +3844,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "7.18.0", @@ -4261,6 +4372,7 @@ "integrity": "sha512-03ruubjWyOHlmljCVoxSuNDdmfZDzsrrz0P2LeJsOXr+ZwFQ+0yQIwNCwt/GYhV7Z31fgtXJTAEs+FYlEL851g==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -4324,12 +4436,13 @@ } }, "node_modules/@types/node": { - "version": "20.16.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.3.tgz", - "integrity": "sha512-/wdGiWRkMOm53gAsSyFMXFZHbVg7C6CbkrzHNpaHoYfsUWPg7m6ZRKtvQjgvQ9i8WT540a3ydRlRQbxjY30XxQ==", + "version": "22.20.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz", + "integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==", "license": "MIT", + "peer": true, "dependencies": { - "undici-types": "~6.19.2" + "undici-types": "~6.21.0" } }, "node_modules/@types/resolve": { @@ -4361,6 +4474,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz", "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -4704,6 +4818,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4748,6 +4863,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5676,6 +5792,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", @@ -6755,6 +6872,15 @@ "url": "https://dotenvx.com" } }, + "node_modules/dset": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", + "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -7201,6 +7327,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -7302,6 +7429,7 @@ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7390,6 +7518,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -10137,6 +10266,12 @@ "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", "license": "MIT" }, + "node_modules/js-cookie": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", + "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -11224,6 +11359,15 @@ "node": ">= 0.4.0" } }, + "node_modules/new-date": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/new-date/-/new-date-1.0.3.tgz", + "integrity": "sha512-0fsVvQPbo2I18DT2zVHpezmeeNYV2JaJSrseiHLc17GNOxJzUdx5mvSigPu8LtIfZSij5i1wXnXFspEs2CD6hA==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@segment/isodate": "1.0.3" + } + }, "node_modules/new-github-release-url": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/new-github-release-url/-/new-github-release-url-2.0.0.tgz", @@ -11335,6 +11479,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/obj-case": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/obj-case/-/obj-case-0.2.1.tgz", + "integrity": "sha512-PquYBBTy+Y6Ob/O2574XHhDtHJlV1cJHMCgW+rDRc9J5hhmRelJB3k5dTK/3cVmFVtzvAKuENeuLpoyTzMzkOg==", + "license": "MIT" + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -12152,6 +12302,7 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -13009,6 +13160,7 @@ "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.9" }, @@ -13224,6 +13376,7 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -14056,6 +14209,12 @@ "node": ">=6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", @@ -14338,6 +14497,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14375,9 +14535,15 @@ } }, "node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unfetch": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/unfetch/-/unfetch-4.2.0.tgz", + "integrity": "sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==", "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { @@ -14633,12 +14799,19 @@ "node": ">= 8" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, "node_modules/webpack": { "version": "5.109.2", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.109.2.tgz", "integrity": "sha512-U9/cvLzxObKNEZ9+TtdqrHM5/9z3lgl2c+c4BzbqGxFQvQvBAq87yql5A8pQ+rrMbS496MZJeF5enVBndIy2hw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", @@ -14755,6 +14928,7 @@ "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", @@ -14897,6 +15071,16 @@ "node": ">= 0.6" } }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/when-exit": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.4.tgz", diff --git a/package.json b/package.json index 684b32d3..a8e3dcaa 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,11 @@ "homepage": "https://github.com/Web3Auth/mpc-core-kit/tree/master#readme", "license": "ISC", "scripts": { + "sync:sdk-version": "node scripts/sync-sdk-version.mjs", + "pretest": "npm run sync:sdk-version", "test": "node --test -r esbuild-register tests/*.spec.ts", "dev": "torus-scripts start", + "prebuild": "npm run sync:sdk-version", "build": "torus-scripts build", "release": "torus-scripts release", "lint": "eslint --fix 'src/**/*.ts'", @@ -43,6 +46,7 @@ } }, "dependencies": { + "@segment/analytics-next": "^1.84.0", "@tkey/common-types": "^15.1.0", "@tkey/core": "^15.1.0", "@tkey/share-serialization": "^15.1.0", @@ -75,7 +79,7 @@ "@types/chai": "^4.3.16", "@types/elliptic": "^6.4.18", "@types/jsonwebtoken": "^9.0.7", - "@types/node": "^20.14.0", + "@types/node": "^22.20.2", "@typescript-eslint/parser": "^7.18.0", "chai": "^5.1.1", "cross-env": "^7.0.3", diff --git a/scripts/sync-sdk-version.mjs b/scripts/sync-sdk-version.mjs new file mode 100644 index 00000000..aeedc02a --- /dev/null +++ b/scripts/sync-sdk-version.mjs @@ -0,0 +1,12 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const { version } = JSON.parse(fs.readFileSync(path.join(scriptDir, "../package.json"), "utf8")); + +const contents = `// Generated from package.json by scripts/sync-sdk-version.mjs. Do not edit. +export const ANALYTICS_SDK_VERSION = ${JSON.stringify(version)}; +`; + +fs.writeFileSync(path.join(scriptDir, "../src/sdkVersion.ts"), contents); diff --git a/src/analytics.ts b/src/analytics.ts new file mode 100644 index 00000000..7282357c --- /dev/null +++ b/src/analytics.ts @@ -0,0 +1,167 @@ +import type { AnalyticsBrowser, EventProperties, UserTraits } from "@segment/analytics-next"; + +import { log } from "./utils"; + +const SEGMENT_WRITE_KEY = "f6LbNqCeVRf512ggdME4b6CyflhF1tsX"; + +export const ANALYTICS_EVENTS = { + SDK_INITIALIZATION_COMPLETED: "SDK Initialization Completed", + SDK_INITIALIZATION_FAILED: "SDK Initialization Failed", + CONNECTION_STARTED: "Connection Started", + CONNECTION_COMPLETED: "Connection Completed", + CONNECTION_FAILED: "Connection Failed", + LOGIN_REQUIRED_SHARE: "Login Required Share", + INPUT_FACTOR_STARTED: "Input Factor Started", + INPUT_FACTOR_COMPLETED: "Input Factor Completed", + INPUT_FACTOR_FAILED: "Input Factor Failed", + MFA_ENABLEMENT_STARTED: "MFA Enablement Started", + MFA_ENABLEMENT_COMPLETED: "MFA Enablement Completed", + MFA_ENABLEMENT_FAILED: "MFA Enablement Failed", + SESSION_REHYDRATION_COMPLETED: "Session Rehydration Completed", + SESSION_REHYDRATION_FAILED: "Session Rehydration Failed", + SESSION_CREATION_FAILED: "Session Creation Failed", + LOGOUT_COMPLETED: "Logout Completed", + LOGOUT_FAILED: "Logout Failed", + FACTOR_CREATION_COMPLETED: "Factor Creation Completed", + FACTOR_CREATION_FAILED: "Factor Creation Failed", + FACTOR_DELETION_COMPLETED: "Factor Deletion Completed", + FACTOR_DELETION_FAILED: "Factor Deletion Failed", +} as const; + +export const ANALYTICS_SDK_NAME = "MPC Core Kit"; +export const ANALYTICS_INTEGRATION_TYPE = "Native SDK"; +export { ANALYTICS_SDK_VERSION } from "./sdkVersion"; + +export type InputFactorFailureReason = "invalid_factor" | "infra_error"; + +export type AnalyticsClient = Pick; +export type AnalyticsClientFactory = () => Promise; + +export interface AnalyticsOptions { + disabled?: boolean; + clientFactory?: AnalyticsClientFactory; +} + +export class Analytics { + private client?: AnalyticsClient; + + private initializationPromise?: Promise; + + private globalProperties: Record = {}; + + private readonly disabled: boolean; + + private readonly clientFactory: AnalyticsClientFactory; + + public constructor(options: AnalyticsOptions) { + this.disabled = Boolean(options.disabled); + this.clientFactory = + options.clientFactory || + (async () => { + const { AnalyticsBrowser } = await import("@segment/analytics-next"); + const segment = new AnalyticsBrowser(); + await segment.load( + { writeKey: SEGMENT_WRITE_KEY }, + { + user: { + cookie: { key: "web3auth_ajs_user_id" }, + localStorage: { key: "web3auth_ajs_user_traits" }, + }, + globalAnalyticsKey: "web3auth_analytics", + } + ); + return segment as AnalyticsClient; + }); + } + + public init(): void { + if (this.isSkipped() || this.initializationPromise) return; + + this.initializationPromise = this.clientFactory() + .then((client) => { + this.client = client; + return client; + }) + .catch((error: unknown): AnalyticsClient | undefined => { + log.error("Failed to initialize analytics", error); + return undefined; + }); + } + + public setGlobalProperties(properties: Record): void { + this.globalProperties = { ...this.globalProperties, ...properties }; + } + + public async identify(userId: string, traits?: UserTraits): Promise { + if (this.isSkipped()) return; + try { + const client = await this.getClient(); + await client?.identify(userId, traits); + } catch (error) { + log.error(`Failed to identify client ${userId} in analytics`, error); + } + } + + public async track(event: string, properties?: EventProperties): Promise { + if (this.isSkipped()) return; + try { + const client = await this.getClient(); + await client?.track(event, { ...this.globalProperties, ...properties }); + } catch (error) { + log.error(`Failed to track event ${event}`, error); + } + } + + private async getClient(): Promise { + if (!this.initializationPromise) this.init(); + return this.client || this.initializationPromise; + } + + private isSkipped(): boolean { + if (this.disabled) return true; + if (typeof window === "undefined") return true; + const dappOrigin = window.location?.origin || ""; + try { + const url = new URL(dappOrigin); + return ( + url.protocol !== "https:" || + url.hostname === "localhost" || + url.hostname === "127.0.0.1" || + url.hostname === "::1" || + url.hostname === "[::1]" + ); + } catch { + return true; + } + } +} + +function sanitizeErrorMessage(message: string): string { + return message + .replace(/\beyJ[\w-]+\.[\w-]+\.[\w-]+\b/g, "[REDACTED_TOKEN]") + .replace(/\b(?:0x)?[a-fA-F0-9]{64,}\b/g, "[REDACTED_KEY]") + .replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, "[REDACTED_EMAIL]") + .slice(0, 500); +} + +export function getErrorAnalyticsProperties(error: unknown): { error_code?: number | string; error_message: string } { + const analyticsError = error as { code?: number | string; message?: string }; + const message = analyticsError?.message || String(error) || "Unknown error"; + return { + ...(analyticsError?.code !== undefined ? { error_code: analyticsError.code } : {}), + error_message: sanitizeErrorMessage(message), + }; +} + +export function getInputFactorFailureReason(error: unknown): InputFactorFailureReason { + const analyticsError = error as { code?: number; message?: string }; + if ( + analyticsError?.code === 1207 || + analyticsError?.code === 1209 || + /invalid factor\s*key/i.test(analyticsError?.message || "") || + analyticsError?.message?.toLowerCase().includes("no metadata found") + ) { + return "invalid_factor"; + } + return "infra_error"; +} diff --git a/src/index.ts b/src/index.ts index 3e41424a..e60b7193 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +export * from "./analytics"; export * from "./constants"; export * from "./helper"; export * from "./interfaces"; diff --git a/src/interfaces.ts b/src/interfaces.ts index fec6ca30..36f00b04 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -378,6 +378,16 @@ export interface Web3AuthOptions { */ enableLogging?: boolean; + /** + * Disables anonymous SDK usage analytics. + * + * Analytics are enabled for browser integrations on secure, non-localhost origins. + * Events include `web3auth_network` so dashboards can filter mainnet vs devnet. + * + * @defaultValue `false` + */ + disableAnalytics?: boolean; + /** * This option is used to specify the url path where user will be * redirected after login. Redirect Uri for OAuth is baseUrl/redirectPathName. diff --git a/src/mpcCoreKit.ts b/src/mpcCoreKit.ts index 3cae7ffa..60eebbf6 100644 --- a/src/mpcCoreKit.ts +++ b/src/mpcCoreKit.ts @@ -29,6 +29,15 @@ import BN from "bn.js"; import bowser from "bowser"; import { ec as EC } from "elliptic"; +import { + Analytics, + ANALYTICS_EVENTS, + ANALYTICS_INTEGRATION_TYPE, + ANALYTICS_SDK_NAME, + ANALYTICS_SDK_VERSION, + getErrorAnalyticsProperties, + getInputFactorFailureReason, +} from "./analytics"; import { ERRORS, FactorKeyTypeShareDescription, @@ -114,6 +123,10 @@ export class Web3AuthMPCCoreKit implements ICoreKit { private socketTransports: string[] = ["websocket", "polling"]; + private analytics: Analytics; + + private suppressFactorAnalytics = false; + constructor(options: Web3AuthOptions) { if (!options.web3AuthClientId) { throw CoreKitError.clientIdInvalid(); @@ -139,6 +152,7 @@ export class Web3AuthMPCCoreKit implements ICoreKit { if (!options.disableHashedFactorKey) options.disableHashedFactorKey = false; if (!options.hashedFactorNonce) options.hashedFactorNonce = options.web3AuthClientId; if (options.disableSessionManager === undefined) options.disableSessionManager = false; + if (options.disableAnalytics === undefined) options.disableAnalytics = false; if (options.socketTransports) this.socketTransports = options.socketTransports; this.options = options as Web3AuthOptionsWithDefaults; @@ -150,6 +164,20 @@ export class Web3AuthMPCCoreKit implements ICoreKit { }); } + this.analytics = new Analytics({ + disabled: options.disableAnalytics || isNodejsOrRN, + }); + this.analytics.setGlobalProperties({ + integration_type: ANALYTICS_INTEGRATION_TYPE, + dapp_url: typeof window === "undefined" ? undefined : window.location?.origin, + sdk_name: ANALYTICS_SDK_NAME, + sdk_version: ANALYTICS_SDK_VERSION, + web3auth_client_id: this.options.web3AuthClientId, + web3auth_network: this.options.web3AuthNetwork, + auth_ux_mode: this.options.uxMode, + key_type: this.keyType, + }); + TorusUtils.setSessionTime(this.options.sessionTime); } @@ -243,89 +271,113 @@ export class Web3AuthMPCCoreKit implements ICoreKit { } public async init(params: InitParams = { handleRedirectResult: true }): Promise { - this.resetState(); - if (params.rehydrate === undefined) params.rehydrate = true; - - const nodeDetails = fetchLocalConfig(this.options.web3AuthNetwork, this.keyType); - - if (this.keyType === KEY_TYPE.ED25519 && this.options.useDKG) { - throw CoreKitError.invalidConfig("DKG is not supported for ed25519 key type"); - } - - this.torusSp = new TSSTorusServiceProvider({ - customAuthArgs: { - web3AuthClientId: this.options.web3AuthClientId, - baseUrl: this.options.baseUrl, - uxMode: this.isNodejsOrRN(this.options.uxMode) ? UX_MODE.REDIRECT : (this.options.uxMode as UX_MODE_TYPE), - network: this.options.web3AuthNetwork, - redirectPathName: this.options.redirectPathName, - locationReplaceOnRedirect: true, - serverTimeOffset: this.options.serverTimeOffset, - keyType: this.keyType, - useDkg: this.options.useDKG, - }, + const startTime = Date.now(); + this.analytics.init(); + void this.analytics.identify(this.options.web3AuthClientId, { + web3auth_client_id: this.options.web3AuthClientId, + web3auth_network: this.options.web3AuthNetwork, }); - this.storageLayer = new TorusStorageLayer({ - hostUrl: `${new URL(nodeDetails.torusNodeEndpoints[0]).origin}/metadata`, - enableLogging: this.enableLogging, - }); + try { + this.resetState(); + if (params.rehydrate === undefined) params.rehydrate = true; - const shareSerializationModule = new ShareSerializationModule(); - - this.tkey = new TKeyTSS({ - enableLogging: this.enableLogging, - serviceProvider: this.torusSp, - storageLayer: this.storageLayer, - manualSync: this.options.manualSync, - modules: { - shareSerialization: shareSerializationModule, - }, - tssKeyType: this.keyType, - }); + const nodeDetails = fetchLocalConfig(this.options.web3AuthNetwork, this.keyType); - if (this.isRedirectMode) { - await this.torusSp.init({ skipSw: true, skipPrefetch: true }); - } else if (this.options.uxMode === UX_MODE.POPUP) { - await this.torusSp.init({}); - } + if (this.keyType === KEY_TYPE.ED25519 && this.options.useDKG) { + throw CoreKitError.invalidConfig("DKG is not supported for ed25519 key type"); + } - this.ready = true; + this.torusSp = new TSSTorusServiceProvider({ + customAuthArgs: { + web3AuthClientId: this.options.web3AuthClientId, + baseUrl: this.options.baseUrl, + uxMode: this.isNodejsOrRN(this.options.uxMode) ? UX_MODE.REDIRECT : (this.options.uxMode as UX_MODE_TYPE), + network: this.options.web3AuthNetwork, + redirectPathName: this.options.redirectPathName, + locationReplaceOnRedirect: true, + serverTimeOffset: this.options.serverTimeOffset, + keyType: this.keyType, + useDkg: this.options.useDKG, + }, + }); - // try handle redirect flow if enabled and return(redirect) from oauth login - if ( - params.handleRedirectResult && - this.options.uxMode === UX_MODE.REDIRECT && - (window?.location.hash.includes("#state") || window?.location.hash.includes("#access_token")) - ) { - // on failed redirect, instance is reseted. - // skip check feature gating on redirection as it was check before login - await this.handleRedirectResult(); + this.storageLayer = new TorusStorageLayer({ + hostUrl: `${new URL(nodeDetails.torusNodeEndpoints[0]).origin}/metadata`, + enableLogging: this.enableLogging, + }); - // return early on successful redirect, the rest of the code will not be executed - return; - } else if (params.rehydrate && this.sessionManager) { - // if not redirect flow try to rehydrate session if available - const sessionId = await this.currentStorage.get("sessionId"); - if (sessionId) { - this.sessionManager.sessionId = sessionId; - - // swallowed, should not throw on rehydrate timed out session - const sessionResult = await this.sessionManager.authorizeSession().catch(async (err) => { - log.error("rehydrate session error", err); - }); + const shareSerializationModule = new ShareSerializationModule(); + + this.tkey = new TKeyTSS({ + enableLogging: this.enableLogging, + serviceProvider: this.torusSp, + storageLayer: this.storageLayer, + manualSync: this.options.manualSync, + modules: { + shareSerialization: shareSerializationModule, + }, + tssKeyType: this.keyType, + }); - // try rehydrate session - if (sessionResult) { - await this.rehydrateSession(sessionResult); + if (this.isRedirectMode) { + await this.torusSp.init({ skipSw: true, skipPrefetch: true }); + } else if (this.options.uxMode === UX_MODE.POPUP) { + await this.torusSp.init({}); + } - // return early on success rehydration - return; + this.ready = true; + + // try handle redirect flow if enabled and return(redirect) from oauth login + if ( + params.handleRedirectResult && + this.options.uxMode === UX_MODE.REDIRECT && + (window?.location.hash.includes("#state") || window?.location.hash.includes("#access_token")) + ) { + // on failed redirect, instance is reseted. + // skip check feature gating on redirection as it was check before login + await this.handleRedirectResult(); + this.trackInitializationCompleted(startTime); + + // return early on successful redirect, the rest of the code will not be executed + return; + } else if (params.rehydrate && this.sessionManager) { + // if not redirect flow try to rehydrate session if available + const sessionId = await this.currentStorage.get("sessionId"); + if (sessionId) { + this.sessionManager.sessionId = sessionId; + const rehydrationStartTime = Date.now(); + + // swallowed, should not throw on rehydrate timed out session + const sessionResult = await this.sessionManager.authorizeSession().catch(async (err) => { + log.error("rehydrate session error", err); + void this.analytics.track(ANALYTICS_EVENTS.SESSION_REHYDRATION_FAILED, { + ...getErrorAnalyticsProperties(err), + duration: Date.now() - rehydrationStartTime, + }); + }); + + // try rehydrate session + if (sessionResult) { + await this.rehydrateSession(sessionResult); + this.trackInitializationCompleted(startTime); + + // return early on success rehydration + return; + } } } + // feature gating if not redirect flow or session rehydration + await this.featureRequest(); + this.trackInitializationCompleted(startTime); + } catch (error) { + void this.analytics.track(ANALYTICS_EVENTS.SDK_INITIALIZATION_FAILED, { + ...this.getInitializationTrackData(), + ...getErrorAnalyticsProperties(error), + duration: Date.now() - startTime, + }); + throw error; } - // feature gating if not redirect flow or session rehydration - await this.featureRequest(); } public async loginWithOAuth(params: OAuthLoginParams): Promise { @@ -348,6 +400,9 @@ export class Web3AuthMPCCoreKit implements ICoreKit { if (this.isRedirectMode && (importTssKey || registerExistingSFAKey)) { throw CoreKitError.invalidConfig("key import is not supported in redirect mode"); } + const startTime = Date.now(); + const trackData = this.getOAuthTrackData(params); + void this.analytics.track(ANALYTICS_EVENTS.CONNECTION_STARTED, trackData); try { // oAuth login. const verifierParams = params as SubVerifierDetailsParams; @@ -391,13 +446,20 @@ export class Web3AuthMPCCoreKit implements ICoreKit { } else { await this.setupTkey(importTssKey, loginResponse, false); } + this.trackConnectionOutcome(startTime, trackData); } catch (err: unknown) { log.error("login error", err); if (err instanceof CoreError) { if (err.code === 1302) { + this.trackRequiredShare(startTime, trackData); throw CoreKitError.default(ERRORS.TKEY_SHARES_REQUIRED); } } + void this.analytics.track(ANALYTICS_EVENTS.CONNECTION_FAILED, { + ...trackData, + ...getErrorAnalyticsProperties(err), + duration: Date.now() - startTime, + }); throw CoreKitError.default((err as Error).message); } } @@ -422,6 +484,9 @@ export class Web3AuthMPCCoreKit implements ICoreKit { throw CoreKitError.invalidConfig("Cannot import TSS key and register SFA key at the same time."); } + const startTime = Date.now(); + const trackData = this.getJWTTrackData(params); + void this.analytics.track(ANALYTICS_EVENTS.CONNECTION_STARTED, trackData); try { // prefetch tss pub keys. const prefetchTssPubs = []; @@ -466,15 +531,22 @@ export class Web3AuthMPCCoreKit implements ICoreKit { } else { await this.setupTkey(importTssKey, loginResponse, false); } + this.trackConnectionOutcome(startTime, trackData); } catch (err: unknown) { log.error("login error", err); if (err instanceof CoreError) { if (err.code === 1302) { + this.trackRequiredShare(startTime, trackData); const newError = CoreKitError.default(ERRORS.TKEY_SHARES_REQUIRED); newError.stack = err.stack; throw newError; } } + void this.analytics.track(ANALYTICS_EVENTS.CONNECTION_FAILED, { + ...trackData, + ...getErrorAnalyticsProperties(err), + duration: Date.now() - startTime, + }); const newError = CoreKitError.default((err as Error).message); newError.stack = (err as Error).stack; throw newError; @@ -487,6 +559,7 @@ export class Web3AuthMPCCoreKit implements ICoreKit { public async handleRedirectResult(): Promise { this.checkReady(); + const startTime = Date.now(); try { const result = await this.torusSp.customAuthInstance.getRedirectResult(); @@ -528,19 +601,48 @@ export class Web3AuthMPCCoreKit implements ICoreKit { this.torusSp.postboxKey = new BN(this.state.postBoxKey, "hex"); this.torusSp.verifierId = userInfo.verifierId; await this.setupTkey(); + this.trackConnectionOutcome(startTime, { + login_method: "redirect", + verifier: userInfo.aggregateVerifier || userInfo.verifier, + is_aggregate_verifier: Boolean(userInfo.aggregateVerifier), + }); } catch (error: unknown) { + const { userInfo } = this.state; + const isRequiredShare = error instanceof CoreError && error.code === 1302; + if (isRequiredShare) { + this.trackRequiredShare(startTime, { + login_method: "redirect", + verifier: userInfo?.aggregateVerifier || userInfo?.verifier, + is_aggregate_verifier: Boolean(userInfo?.aggregateVerifier), + }); + } this.resetState(); log.error("error while handling redirect result", error); + if (!isRequiredShare) { + void this.analytics.track(ANALYTICS_EVENTS.CONNECTION_FAILED, { + login_method: "redirect", + ...getErrorAnalyticsProperties(error), + duration: Date.now() - startTime, + }); + } throw CoreKitError.default((error as Error).message); } } public async inputFactorKey(factorKey: BN): Promise { this.checkReady(); + const startTime = Date.now(); + const completesLogin = this.status === COREKIT_STATUS.REQUIRED_SHARE; + if (!this.suppressFactorAnalytics) void this.analytics.track(ANALYTICS_EVENTS.INPUT_FACTOR_STARTED); try { // always check for valid factor key - const factorKeyPrivate = factorKeyCurve.keyFromPrivate(factorKey.toBuffer()); - const factorPubX = factorKeyPrivate.getPublic().getX().toString("hex").padStart(64, "0"); + let factorPubX: string; + try { + const factorKeyPrivate = factorKeyCurve.keyFromPrivate(factorKey.toBuffer()); + factorPubX = factorKeyPrivate.getPublic().getX().toString("hex").padStart(64, "0"); + } catch { + throw CoreKitError.providedFactorKeyInvalid("Invalid FactorKey provided. Failed to derive its public key."); + } const factorEncExist = this.tkey.metadata.factorEncs?.[this.tkey.tssTag]?.[factorPubX]; if (!factorEncExist) { throw CoreKitError.providedFactorKeyInvalid("Invalid FactorKey provided. Failed to input factor key."); @@ -556,8 +658,28 @@ export class Web3AuthMPCCoreKit implements ICoreKit { // Finalize initialization. await this.tKey.reconstructKey(); await this.finalizeTkey(factorKey); + if (!this.suppressFactorAnalytics) { + void this.analytics.track(ANALYTICS_EVENTS.INPUT_FACTOR_COMPLETED, { + factor_share_type: this.state.tssShareIndex, + duration: Date.now() - startTime, + }); + } + if (completesLogin && !this.suppressFactorAnalytics) { + void this.analytics.track(ANALYTICS_EVENTS.CONNECTION_COMPLETED, { + completion_method: "input_factor", + factor_share_type: this.state.tssShareIndex, + duration: Date.now() - startTime, + }); + } } catch (err: unknown) { log.error("login error", err); + if (!this.suppressFactorAnalytics) { + void this.analytics.track(ANALYTICS_EVENTS.INPUT_FACTOR_FAILED, { + failure_reason: getInputFactorFailureReason(err), + ...getErrorAnalyticsProperties(err), + duration: Date.now() - startTime, + }); + } if (err instanceof CoreError) { if (err.code === 1302) { throw CoreKitError.default(ERRORS.TKEY_SHARES_REQUIRED); @@ -592,56 +714,81 @@ export class Web3AuthMPCCoreKit implements ICoreKit { public async enableMFA(enableMFAParams: EnableMFAParams, recoveryFactor = true): Promise { this.checkReady(); + const startTime = Date.now(); + let mutationStarted = false; + void this.analytics.track(ANALYTICS_EVENTS.MFA_ENABLEMENT_STARTED, { + auth_ux_mode: this.options.uxMode, + recovery_factor_enabled: recoveryFactor, + }); - const { postBoxKey } = this.state; - const hashedFactorKey = getHashedPrivateKey(postBoxKey, this.options.hashedFactorNonce); - if (!(await this.checkIfFactorKeyValid(hashedFactorKey))) { - if (this.tKey._localMetadataTransitions[0].length) { - throw CoreKitError.commitChangesBeforeMFA(); + try { + const { postBoxKey } = this.state; + const hashedFactorKey = getHashedPrivateKey(postBoxKey, this.options.hashedFactorNonce); + if (!(await this.checkIfFactorKeyValid(hashedFactorKey))) { + if (this.tKey._localMetadataTransitions[0].length) { + throw CoreKitError.commitChangesBeforeMFA(); + } + throw CoreKitError.mfaAlreadyEnabled(); } - throw CoreKitError.mfaAlreadyEnabled(); - } - - return this.atomicSync(async () => { - let browserData; - if (this.isNodejsOrRN(this.options.uxMode)) { - browserData = { - browserName: "Node Env", - browserVersion: "", - deviceName: "nodejs", - }; - } else { - // try { - const browserInfo = bowser.parse(navigator.userAgent); - const browserName = `${browserInfo.browser.name}`; - browserData = { - browserName, - browserVersion: browserInfo.browser.version, - deviceName: browserInfo.os.name, - }; - } - const deviceFactorKey = new BN(await this.createFactor({ shareType: TssShareType.DEVICE, additionalMetadata: browserData }), "hex"); - await this.setDeviceFactor(deviceFactorKey); - await this.inputFactorKey(new BN(deviceFactorKey, "hex")); + mutationStarted = true; + this.suppressFactorAnalytics = true; + const backupFactorKey = await this.atomicSync(async () => { + let browserData; + + if (this.isNodejsOrRN(this.options.uxMode)) { + browserData = { + browserName: "Node Env", + browserVersion: "", + deviceName: "nodejs", + }; + } else { + const browserInfo = bowser.parse(navigator.userAgent); + const browserName = `${browserInfo.browser.name}`; + browserData = { + browserName, + browserVersion: browserInfo.browser.version, + deviceName: browserInfo.os.name, + }; + } + const deviceFactorKey = new BN(await this.createFactor({ shareType: TssShareType.DEVICE, additionalMetadata: browserData }), "hex"); + await this.setDeviceFactor(deviceFactorKey); + await this.inputFactorKey(new BN(deviceFactorKey, "hex")); - const hashedFactorPub = getPubKeyPoint(hashedFactorKey, factorKeyCurve); - await this.deleteFactor(hashedFactorPub, hashedFactorKey); + const hashedFactorPub = getPubKeyPoint(hashedFactorKey, factorKeyCurve); + await this.deleteFactor(hashedFactorPub, hashedFactorKey); - // only recovery factor = true - let backupFactorKey; - if (recoveryFactor) { - backupFactorKey = await this.createFactor({ shareType: TssShareType.RECOVERY, ...enableMFAParams }); - } + // only recovery factor = true + let recoveryFactorKey: string | undefined; + if (recoveryFactor) { + recoveryFactorKey = await this.createFactor({ shareType: TssShareType.RECOVERY, ...enableMFAParams }); + } + return recoveryFactorKey; + }); + this.suppressFactorAnalytics = false; + void this.analytics.track(ANALYTICS_EVENTS.MFA_ENABLEMENT_COMPLETED, { + auth_ux_mode: this.options.uxMode, + is_mfa_enabled: true, + recovery_factor_created: Boolean(backupFactorKey), + duration: Date.now() - startTime, + }); // update to undefined for next major release - return backupFactorKey; - }).catch((reason: Error) => { - log.error("error enabling MFA:", reason.message); - const err = CoreKitError.default(reason.message); - err.stack = reason.stack; + return backupFactorKey as string; + } catch (reason) { + this.suppressFactorAnalytics = false; + const error = reason as Error; + log.error("error enabling MFA:", error.message); + void this.analytics.track(ANALYTICS_EVENTS.MFA_ENABLEMENT_FAILED, { + auth_ux_mode: this.options.uxMode, + ...getErrorAnalyticsProperties(reason), + duration: Date.now() - startTime, + }); + if (!mutationStarted) throw reason; + const err = CoreKitError.default(error.message); + err.stack = error.stack; throw err; - }); + } } public getTssFactorPub = (): string[] => { @@ -656,41 +803,64 @@ export class Web3AuthMPCCoreKit implements ICoreKit { // mutation function public async createFactor(createFactorParams: CreateFactorParams): Promise { this.checkReady(); + const startTime = Date.now(); const { shareType } = createFactorParams; + let mutationStarted = false; - let { factorKey, shareDescription, additionalMetadata } = createFactorParams; + try { + let { factorKey, shareDescription, additionalMetadata } = createFactorParams; - if (!VALID_SHARE_INDICES.includes(shareType)) { - throw CoreKitError.newShareIndexInvalid(`Invalid share type provided (${shareType}). Valid share types are ${VALID_SHARE_INDICES}.`); - } - if (!factorKey) { - factorKey = generateFactorKey().private; - } - if (!shareDescription) { - shareDescription = FactorKeyTypeShareDescription.Other; - } - if (!additionalMetadata) { - additionalMetadata = {}; - } + if (!VALID_SHARE_INDICES.includes(shareType)) { + throw CoreKitError.newShareIndexInvalid(`Invalid share type provided (${shareType}). Valid share types are ${VALID_SHARE_INDICES}.`); + } + if (!factorKey) { + factorKey = generateFactorKey().private; + } + if (!shareDescription) { + shareDescription = FactorKeyTypeShareDescription.Other; + } + if (!additionalMetadata) { + additionalMetadata = {}; + } - const factorPub = getPubKeyPoint(factorKey, factorKeyCurve); + const factorPub = getPubKeyPoint(factorKey, factorKeyCurve); - if (this.getTssFactorPub().includes(factorPub.toSEC1(factorKeyCurve, true).toString("hex"))) { - throw CoreKitError.factorKeyAlreadyExists(); - } + if (this.getTssFactorPub().includes(factorPub.toSEC1(factorKeyCurve, true).toString("hex"))) { + throw CoreKitError.factorKeyAlreadyExists(); + } - return this.atomicSync(async () => { - await this.copyOrCreateShare(shareType, factorPub); - await this.backupMetadataShare(factorKey); - await this.addFactorDescription({ factorKey, shareDescription, additionalMetadata, updateMetadata: false }); + mutationStarted = true; + const result = await this.atomicSync(async () => { + await this.copyOrCreateShare(shareType, factorPub); + await this.backupMetadataShare(factorKey); + await this.addFactorDescription({ factorKey, shareDescription, additionalMetadata, updateMetadata: false }); - return scalarBNToBufferSEC1(factorKey).toString("hex"); - }).catch((reason: Error) => { - log.error("error creating factor:", reason.message); - const err = CoreKitError.default(`error creating factor: ${reason.message}`); - err.stack = reason.stack; + return scalarBNToBufferSEC1(factorKey).toString("hex"); + }); + if (!this.suppressFactorAnalytics) { + void this.analytics.track(ANALYTICS_EVENTS.FACTOR_CREATION_COMPLETED, { + factor_share_type: shareType, + share_description: shareDescription, + duration: Date.now() - startTime, + }); + } + return result; + } catch (reason) { + const error = reason as Error; + log.error("error creating factor:", error.message); + if (!this.suppressFactorAnalytics) { + void this.analytics.track(ANALYTICS_EVENTS.FACTOR_CREATION_FAILED, { + factor_share_type: shareType, + share_description: createFactorParams.shareDescription, + ...getErrorAnalyticsProperties(reason), + duration: Date.now() - startTime, + }); + } + if (!mutationStarted) throw reason; + const err = CoreKitError.default(`error creating factor: ${error.message}`); + err.stack = error.stack; throw err; - }); + } } /** @@ -698,6 +868,9 @@ export class Web3AuthMPCCoreKit implements ICoreKit { */ public getPubKey(): Buffer { const { tssPubKey } = this.state; + if (!tssPubKey) { + throw CoreKitError.tssPublicKeyOrEndpointsMissing("tssPubKey not present in state when getting public key."); + } return Buffer.from(tssPubKey); } @@ -826,53 +999,77 @@ export class Web3AuthMPCCoreKit implements ICoreKit { // mutation function async deleteFactor(factorPub: Point, factorKey?: BNString): Promise { - if (!this.state.factorKey) { - throw CoreKitError.factorKeyNotPresent("factorKey not present in state when deleting a factor."); - } - if (!this.tKey.metadata.factorPubs) { - throw CoreKitError.factorPubsMissing(); - } - - await this.atomicSync(async () => { - const remainingFactors = this.tKey.metadata.factorPubs[this.tKey.tssTag].length || 0; - if (remainingFactors <= 1) { - throw CoreKitError.cannotDeleteLastFactor("Cannot delete last factor"); + const startTime = Date.now(); + try { + if (!this.state.factorKey) { + throw CoreKitError.factorKeyNotPresent("factorKey not present in state when deleting a factor."); } - const fpp = factorPub; - const stateFpp = getPubKeyPoint(this.state.factorKey, factorKeyCurve); - if (fpp.equals(stateFpp)) { - throw CoreKitError.factorInUseCannotBeDeleted("Cannot delete current active factor"); + if (!this.tKey.metadata.factorPubs) { + throw CoreKitError.factorPubsMissing(); } - await this.tKey.deleteFactorPub({ factorKey: this.state.factorKey, deleteFactorPub: factorPub, authSignatures: this.signatures }); - const factorPubHex = fpp.toSEC1(factorKeyCurve, true).toString("hex"); - const allDesc = this.tKey.metadata.getShareDescription(); - const keyDesc = allDesc[factorPubHex]; - if (keyDesc) { - await Promise.all(keyDesc.map(async (desc) => this.tKey?.metadata.deleteShareDescription(factorPubHex, desc))); - } + await this.atomicSync(async () => { + const remainingFactors = this.tKey.metadata.factorPubs[this.tKey.tssTag].length || 0; + if (remainingFactors <= 1) { + throw CoreKitError.cannotDeleteLastFactor("Cannot delete last factor"); + } + const fpp = factorPub; + const stateFpp = getPubKeyPoint(this.state.factorKey, factorKeyCurve); + if (fpp.equals(stateFpp)) { + throw CoreKitError.factorInUseCannotBeDeleted("Cannot delete current active factor"); + } - // delete factorKey share metadata if factorkey is provided - if (factorKey) { - const factorKeyBN = new BN(factorKey, "hex"); - const derivedFactorPub = getPubKeyPoint(factorKeyBN, factorKeyCurve); - // only delete if factorPub matches - if (derivedFactorPub.equals(fpp)) { - await this.deleteMetadataShareBackup(factorKeyBN); + await this.tKey.deleteFactorPub({ factorKey: this.state.factorKey, deleteFactorPub: factorPub, authSignatures: this.signatures }); + const factorPubHex = fpp.toSEC1(factorKeyCurve, true).toString("hex"); + const allDesc = this.tKey.metadata.getShareDescription(); + const keyDesc = allDesc[factorPubHex]; + if (keyDesc) { + await Promise.all(keyDesc.map(async (desc) => this.tKey?.metadata.deleteShareDescription(factorPubHex, desc))); } + + // delete factorKey share metadata if factorkey is provided + if (factorKey) { + const factorKeyBN = new BN(factorKey, "hex"); + const derivedFactorPub = getPubKeyPoint(factorKeyBN, factorKeyCurve); + // only delete if factorPub matches + if (derivedFactorPub.equals(fpp)) { + await this.deleteMetadataShareBackup(factorKeyBN); + } + } + }); + if (!this.suppressFactorAnalytics) { + void this.analytics.track(ANALYTICS_EVENTS.FACTOR_DELETION_COMPLETED, { duration: Date.now() - startTime }); } - }); + } catch (error) { + if (!this.suppressFactorAnalytics) { + void this.analytics.track(ANALYTICS_EVENTS.FACTOR_DELETION_FAILED, { + ...getErrorAnalyticsProperties(error), + duration: Date.now() - startTime, + }); + } + throw error; + } } public async logout(): Promise { - if (this.sessionManager?.sessionId) { - await this.sessionManager.invalidateSession(); - } - // to accommodate async storage - await this.currentStorage.set("sessionId", ""); + const startTime = Date.now(); + try { + if (this.sessionManager?.sessionId) { + await this.sessionManager.invalidateSession(); + } + // to accommodate async storage + await this.currentStorage.set("sessionId", ""); - this.resetState(); - await this.init({ handleRedirectResult: false, rehydrate: false }); + this.resetState(); + await this.init({ handleRedirectResult: false, rehydrate: false }); + void this.analytics.track(ANALYTICS_EVENTS.LOGOUT_COMPLETED, { duration: Date.now() - startTime }); + } catch (error) { + void this.analytics.track(ANALYTICS_EVENTS.LOGOUT_FAILED, { + ...getErrorAnalyticsProperties(error), + duration: Date.now() - startTime, + }); + throw error; + } } public getUserInfo(): UserInfo { @@ -1193,6 +1390,7 @@ export class Web3AuthMPCCoreKit implements ICoreKit { } private async rehydrateSession(result: SessionData) { + const startTime = Date.now(); try { this.checkReady(); @@ -1229,8 +1427,16 @@ export class Web3AuthMPCCoreKit implements ICoreKit { await this.setDeviceFactor(this.state.factorKey); } } + void this.analytics.track(ANALYTICS_EVENTS.SESSION_REHYDRATION_COMPLETED, { + factor_share_type: this.state.tssShareIndex, + duration: Date.now() - startTime, + }); } catch (err) { log.warn("failed to authorize session please use new instance without rehydration", err); + void this.analytics.track(ANALYTICS_EVENTS.SESSION_REHYDRATION_FAILED, { + ...getErrorAnalyticsProperties(err), + duration: Date.now() - startTime, + }); } } @@ -1269,6 +1475,7 @@ export class Web3AuthMPCCoreKit implements ICoreKit { await this.currentStorage.set("sessionId", sessionId); } catch (err) { log.error("error creating session", err); + void this.analytics.track(ANALYTICS_EVENTS.SESSION_CREATION_FAILED, getErrorAnalyticsProperties(err)); } } @@ -1398,6 +1605,73 @@ export class Web3AuthMPCCoreKit implements ICoreKit { await this.tKey?.addShareDescription(factorPub, JSON.stringify(params), updateMetadata); } + private getInitializationTrackData(): Record { + const storageType = typeof this.options.storage === "string" ? this.options.storage : "async" in this.options.storage ? "async_custom" : "custom"; + return { + auth_ux_mode: this.options.uxMode, + logging_enabled: this.enableLogging, + storage_type: storageType, + key_type: this.keyType, + manual_sync: this.options.manualSync, + hashed_factor_enabled: !this.options.disableHashedFactorKey, + session_manager_enabled: !this.options.disableSessionManager, + use_dkg: this.options.useDKG, + }; + } + + private trackInitializationCompleted(startTime: number): void { + void this.analytics.track(ANALYTICS_EVENTS.SDK_INITIALIZATION_COMPLETED, { + ...this.getInitializationTrackData(), + duration: Date.now() - startTime, + }); + } + + private getOAuthTrackData(params: OAuthLoginParams): Record { + if ("subVerifierDetails" in params) { + return { + login_method: "oauth", + verifier: params.subVerifierDetails.verifier, + auth_connection: params.subVerifierDetails.typeOfLogin, + is_aggregate_verifier: false, + }; + } + return { + login_method: "oauth", + verifier: params.aggregateVerifierIdentifier, + auth_connection: params.subVerifierDetailsArray[0]?.typeOfLogin, + is_aggregate_verifier: true, + }; + } + + private getJWTTrackData(params: JWTLoginParams): Record { + return { + login_method: "jwt", + verifier: params.verifier, + is_aggregate_verifier: Boolean(params.subVerifier), + is_sfa: true, + }; + } + + private trackConnectionOutcome(startTime: number, trackData: Record): void { + if (this.status === COREKIT_STATUS.LOGGED_IN) { + void this.analytics.track(ANALYTICS_EVENTS.CONNECTION_COMPLETED, { + ...trackData, + corekit_status: this.status, + duration: Date.now() - startTime, + }); + } else if (this.status === COREKIT_STATUS.REQUIRED_SHARE) { + this.trackRequiredShare(startTime, trackData); + } + } + + private trackRequiredShare(startTime: number, trackData: Record): void { + void this.analytics.track(ANALYTICS_EVENTS.LOGIN_REQUIRED_SHARE, { + ...trackData, + corekit_status: COREKIT_STATUS.REQUIRED_SHARE, + duration: Date.now() - startTime, + }); + } + private resetState(): void { this.ready = false; this.tkey = null; diff --git a/src/sdkVersion.ts b/src/sdkVersion.ts new file mode 100644 index 00000000..89e775c4 --- /dev/null +++ b/src/sdkVersion.ts @@ -0,0 +1,2 @@ +// Generated from package.json by scripts/sync-sdk-version.mjs. Do not edit. +export const ANALYTICS_SDK_VERSION = "3.6.0"; diff --git a/tests/analytics.spec.ts b/tests/analytics.spec.ts new file mode 100644 index 00000000..54bd3563 --- /dev/null +++ b/tests/analytics.spec.ts @@ -0,0 +1,179 @@ +import assert from "assert"; +import test from "node:test"; + +import { + ANALYTICS_EVENTS, + ANALYTICS_SDK_VERSION, + Analytics, + AnalyticsClient, + getErrorAnalyticsProperties, + getInputFactorFailureReason, + WEB3AUTH_NETWORK, +} from "../src"; +import { version as packageVersion } from "../package.json"; + +test("analytics SDK version matches package.json", () => { + assert.strictEqual(ANALYTICS_SDK_VERSION, packageVersion); +}); + +test("analytics identifies by client id and includes global properties", async () => { + const identifyCalls: unknown[][] = []; + const trackCalls: unknown[][] = []; + const originalWindow = globalThis.window; + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { location: { origin: "https://example.com" } }, + }); + + try { + const client = { + identify: async (...args: unknown[]) => { + identifyCalls.push(args); + }, + track: async (...args: unknown[]) => { + trackCalls.push(args); + }, + } as unknown as AnalyticsClient; + const analytics = new Analytics({ + clientFactory: async () => client, + }); + analytics.setGlobalProperties({ sdk_name: "MPC Core Kit", duration: 99 }); + analytics.init(); + + await analytics.identify("client-id", { web3auth_client_id: "client-id" }); + await analytics.track(ANALYTICS_EVENTS.CONNECTION_COMPLETED, { duration: 10 }); + + assert.strictEqual(identifyCalls[0][0], "client-id"); + assert.deepStrictEqual(trackCalls[0], [ + ANALYTICS_EVENTS.CONNECTION_COMPLETED, + { sdk_name: "MPC Core Kit", duration: 10 }, + ]); + } finally { + Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow }); + } +}); + +test("analytics sends events on all networks", async () => { + const originalWindow = globalThis.window; + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { location: { origin: "https://example.com" } }, + }); + try { + for (const network of [WEB3AUTH_NETWORK.DEVNET, WEB3AUTH_NETWORK.MAINNET]) { + const trackCalls: unknown[][] = []; + const analytics = new Analytics({ + clientFactory: async (): Promise => + ({ + identify: async (): Promise => undefined, + track: async (...args: unknown[]): Promise => { + trackCalls.push(args); + }, + }) as unknown as AnalyticsClient, + }); + analytics.setGlobalProperties({ web3auth_network: network }); + analytics.init(); + await analytics.track(ANALYTICS_EVENTS.CONNECTION_COMPLETED); + assert.strictEqual(trackCalls[0][0], ANALYTICS_EVENTS.CONNECTION_COMPLETED); + assert.deepStrictEqual(trackCalls[0][1], { web3auth_network: network }); + } + } finally { + Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow }); + } +}); + +test("analytics skips insecure and local origins", async () => { + const originalWindow = globalThis.window; + try { + for (const origin of ["http://example.com", "https://localhost", "https://127.0.0.1", "https://[::1]", "null"]) { + let factoryCalled = false; + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { location: { origin } }, + }); + const analytics = new Analytics({ + clientFactory: async () => { + factoryCalled = true; + return {} as AnalyticsClient; + }, + }); + analytics.init(); + await analytics.track(ANALYTICS_EVENTS.SDK_INITIALIZATION_COMPLETED); + assert.strictEqual(factoryCalled, false, `analytics should skip ${origin}`); + } + } finally { + Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow }); + } +}); + +test("analytics skips when no browser window is available", async () => { + const originalWindow = globalThis.window; + let factoryCalled = false; + Object.defineProperty(globalThis, "window", { configurable: true, value: undefined }); + try { + const analytics = new Analytics({ + clientFactory: async () => { + factoryCalled = true; + return {} as AnalyticsClient; + }, + }); + analytics.init(); + await analytics.track(ANALYTICS_EVENTS.SDK_INITIALIZATION_COMPLETED); + assert.strictEqual(factoryCalled, false); + } finally { + Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow }); + } +}); + +test("analytics can be disabled and never propagates client errors", async () => { + const disabledAnalytics = new Analytics({ + disabled: true, + clientFactory: async () => { + throw new Error("must not initialize"); + }, + }); + disabledAnalytics.init(); + await disabledAnalytics.track(ANALYTICS_EVENTS.CONNECTION_STARTED); + + const originalWindow = globalThis.window; + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { location: { origin: "https://example.com" } }, + }); + try { + const failingAnalytics = new Analytics({ + clientFactory: async () => + ({ + identify: async () => { + throw new Error("identify failed"); + }, + track: async () => { + throw new Error("track failed"); + }, + }) as unknown as AnalyticsClient, + }); + failingAnalytics.init(); + await assert.doesNotReject(() => failingAnalytics.identify("client-id")); + await assert.doesNotReject(() => failingAnalytics.track(ANALYTICS_EVENTS.CONNECTION_STARTED)); + } finally { + Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow }); + } +}); + +test("input factor errors are split into user and infrastructure buckets", () => { + assert.strictEqual(getInputFactorFailureReason({ code: 1207, message: "invalid factor" }), "invalid_factor"); + assert.strictEqual(getInputFactorFailureReason({ code: 1209, message: "metadata missing" }), "invalid_factor"); + assert.strictEqual(getInputFactorFailureReason(new Error("invalid factor key")), "invalid_factor"); + assert.strictEqual(getInputFactorFailureReason(new Error("network unavailable")), "infra_error"); +}); + +test("analytics errors redact tokens and key material", () => { + const token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.VerySecretSignature"; + const key = "a".repeat(64); + const email = "person@example.com"; + const properties = getErrorAnalyticsProperties(new Error(`token ${token} key ${key} email ${email}`)); + + assert.strictEqual(properties.error_message.includes(token), false); + assert.strictEqual(properties.error_message.includes(key), false); + assert.strictEqual(properties.error_message.includes(email), false); +}); From 8113e96c44672ed23c9a1a4bbd9d768da1a8b05d Mon Sep 17 00:00:00 2001 From: Ganesh Suresh Patra Date: Thu, 10 Sep 2026 17:32:33 +0530 Subject: [PATCH 2/6] fix: align enableMFA return type and guard getPubKeyPoint Return undefined from enableMFA when no recovery factor is created, and throw CoreKitError when tssPubKey is missing in getPubKeyPoint. Co-authored-by: Cursor --- src/interfaces.ts | 4 ++-- src/mpcCoreKit.ts | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/interfaces.ts b/src/interfaces.ts index 36f00b04..7e88278e 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -237,9 +237,9 @@ export interface ICoreKit { * * @param enableMFAParams - Parameters for recovery factor for MFA. * @param recoveryFactor - Default is true. If false, recovery factor will NOT be created. - * @returns The backup factor key if if recoveryFacort is true else empty string. + * @returns The backup factor key when recoveryFactor is true; otherwise undefined. */ - enableMFA(enableMFAParams: EnableMFAParams, recoveryFactor?: boolean): Promise; + enableMFA(enableMFAParams: EnableMFAParams, recoveryFactor?: boolean): Promise; /** * Second step for login where the user inputs their factor key. diff --git a/src/mpcCoreKit.ts b/src/mpcCoreKit.ts index 60eebbf6..09c400fc 100644 --- a/src/mpcCoreKit.ts +++ b/src/mpcCoreKit.ts @@ -712,7 +712,7 @@ export class Web3AuthMPCCoreKit implements ICoreKit { } } - public async enableMFA(enableMFAParams: EnableMFAParams, recoveryFactor = true): Promise { + public async enableMFA(enableMFAParams: EnableMFAParams, recoveryFactor = true): Promise { this.checkReady(); const startTime = Date.now(); let mutationStarted = false; @@ -773,8 +773,7 @@ export class Web3AuthMPCCoreKit implements ICoreKit { recovery_factor_created: Boolean(backupFactorKey), duration: Date.now() - startTime, }); - // update to undefined for next major release - return backupFactorKey as string; + return backupFactorKey; } catch (reason) { this.suppressFactorAnalytics = false; const error = reason as Error; @@ -879,6 +878,9 @@ export class Web3AuthMPCCoreKit implements ICoreKit { */ public getPubKeyPoint(): Point { const { tssPubKey } = this.state; + if (!tssPubKey) { + throw CoreKitError.tssPublicKeyOrEndpointsMissing("tssPubKey not present in state when getting public key point."); + } return Point.fromSEC1(this.tkey.tssCurve, tssPubKey.toString("hex")); } From 159d869a2718e63e689e4ccb78e373dff8e2439b Mon Sep 17 00:00:00 2001 From: Ganesh Suresh Patra Date: Thu, 10 Sep 2026 18:01:53 +0530 Subject: [PATCH 3/6] fix: stop analytics from double-counting init, logout, and redirect login Skip SDK init events on logout and TSS recovery resets, record redirect Connection Started on return, and label rehydrate failures instead of reporting them as completed. Co-authored-by: Cursor --- src/analytics.ts | 3 ++ src/mpcCoreKit.ts | 92 +++++++++++++++++++++++++++++++++-------------- 2 files changed, 69 insertions(+), 26 deletions(-) diff --git a/src/analytics.ts b/src/analytics.ts index 7282357c..be971740 100644 --- a/src/analytics.ts +++ b/src/analytics.ts @@ -2,6 +2,9 @@ import type { AnalyticsBrowser, EventProperties, UserTraits } from "@segment/ana import { log } from "./utils"; +// Public Segment *source* write key (write-only). This is the same key used by +// web3auth-web. It is not a secret: browser SDKs must ship it in the bundle. +// Mixpanel isolation is via sdk_name / web3auth_client_id, not a private key. const SEGMENT_WRITE_KEY = "f6LbNqCeVRf512ggdME4b6CyflhF1tsX"; export const ANALYTICS_EVENTS = { diff --git a/src/mpcCoreKit.ts b/src/mpcCoreKit.ts index 09c400fc..8a157848 100644 --- a/src/mpcCoreKit.ts +++ b/src/mpcCoreKit.ts @@ -127,6 +127,8 @@ export class Web3AuthMPCCoreKit implements ICoreKit { private suppressFactorAnalytics = false; + private skipInitAnalytics = false; + constructor(options: Web3AuthOptions) { if (!options.web3AuthClientId) { throw CoreKitError.clientIdInvalid(); @@ -256,7 +258,12 @@ export class Web3AuthMPCCoreKit implements ICoreKit { const { tssIndex, tssShare } = await this.tKey.getTSSShare(factorKeyBNInput); if (tssIndexes.includes(tssIndex)) { // reset instance before throw error - await this.init(); + this.skipInitAnalytics = true; + try { + await this.init(); + } finally { + this.skipInitAnalytics = false; + } throw CoreKitError.duplicateTssIndex(); } tssIndexes.push(tssIndex); @@ -266,17 +273,25 @@ export class Web3AuthMPCCoreKit implements ICoreKit { const finalKey = lagrangeInterpolation(this.tkey.tssCurve, tssShares, tssIndexesBN); // reset instance after recovery completed - await this.init(); + this.skipInitAnalytics = true; + try { + await this.init(); + } finally { + this.skipInitAnalytics = false; + } return finalKey.toString("hex", 64); } public async init(params: InitParams = { handleRedirectResult: true }): Promise { const startTime = Date.now(); this.analytics.init(); - void this.analytics.identify(this.options.web3AuthClientId, { - web3auth_client_id: this.options.web3AuthClientId, - web3auth_network: this.options.web3AuthNetwork, - }); + if (!this.skipInitAnalytics) { + void this.analytics.identify(this.options.web3AuthClientId, { + web3auth_client_id: this.options.web3AuthClientId, + web3auth_network: this.options.web3AuthNetwork, + }); + } + let skipSdkInitializationFailed = false; try { this.resetState(); @@ -336,8 +351,15 @@ export class Web3AuthMPCCoreKit implements ICoreKit { ) { // on failed redirect, instance is reseted. // skip check feature gating on redirection as it was check before login - await this.handleRedirectResult(); - this.trackInitializationCompleted(startTime); + // Connection Started/Completed/Failed are tracked inside handleRedirectResult. + // Login errors from redirect must not also count as SDK initialization failures. + try { + await this.handleRedirectResult(); + } catch (error) { + skipSdkInitializationFailed = true; + throw error; + } + this.trackInitializationCompleted(startTime, { session_rehydration: "skipped" }); // return early on successful redirect, the rest of the code will not be executed return; @@ -359,23 +381,29 @@ export class Web3AuthMPCCoreKit implements ICoreKit { // try rehydrate session if (sessionResult) { - await this.rehydrateSession(sessionResult); - this.trackInitializationCompleted(startTime); + const rehydrated = await this.rehydrateSession(sessionResult); + this.trackInitializationCompleted(startTime, { session_rehydration: rehydrated ? "completed" : "failed" }); - // return early on success rehydration + // return early whether rehydrate succeeded or swallowed an error return; } + + await this.featureRequest(); + this.trackInitializationCompleted(startTime, { session_rehydration: "failed" }); + return; } } // feature gating if not redirect flow or session rehydration await this.featureRequest(); - this.trackInitializationCompleted(startTime); + this.trackInitializationCompleted(startTime, { session_rehydration: "skipped" }); } catch (error) { - void this.analytics.track(ANALYTICS_EVENTS.SDK_INITIALIZATION_FAILED, { - ...this.getInitializationTrackData(), - ...getErrorAnalyticsProperties(error), - duration: Date.now() - startTime, - }); + if (!this.skipInitAnalytics && !skipSdkInitializationFailed) { + void this.analytics.track(ANALYTICS_EVENTS.SDK_INITIALIZATION_FAILED, { + ...this.getInitializationTrackData(), + ...getErrorAnalyticsProperties(error), + duration: Date.now() - startTime, + }); + } throw error; } } @@ -402,7 +430,11 @@ export class Web3AuthMPCCoreKit implements ICoreKit { } const startTime = Date.now(); const trackData = this.getOAuthTrackData(params); - void this.analytics.track(ANALYTICS_EVENTS.CONNECTION_STARTED, trackData); + // Redirect unloads the page before Segment can reliably send; the return + // path in handleRedirectResult records Connection Started instead. + if (!this.isRedirectMode) { + void this.analytics.track(ANALYTICS_EVENTS.CONNECTION_STARTED, trackData); + } try { // oAuth login. const verifierParams = params as SubVerifierDetailsParams; @@ -560,6 +592,7 @@ export class Web3AuthMPCCoreKit implements ICoreKit { public async handleRedirectResult(): Promise { this.checkReady(); const startTime = Date.now(); + void this.analytics.track(ANALYTICS_EVENTS.CONNECTION_STARTED, { login_method: "redirect" }); try { const result = await this.torusSp.customAuthInstance.getRedirectResult(); @@ -804,10 +837,11 @@ export class Web3AuthMPCCoreKit implements ICoreKit { this.checkReady(); const startTime = Date.now(); const { shareType } = createFactorParams; + const shareDescription = createFactorParams.shareDescription || FactorKeyTypeShareDescription.Other; let mutationStarted = false; try { - let { factorKey, shareDescription, additionalMetadata } = createFactorParams; + let { factorKey, additionalMetadata } = createFactorParams; if (!VALID_SHARE_INDICES.includes(shareType)) { throw CoreKitError.newShareIndexInvalid(`Invalid share type provided (${shareType}). Valid share types are ${VALID_SHARE_INDICES}.`); @@ -815,9 +849,6 @@ export class Web3AuthMPCCoreKit implements ICoreKit { if (!factorKey) { factorKey = generateFactorKey().private; } - if (!shareDescription) { - shareDescription = FactorKeyTypeShareDescription.Other; - } if (!additionalMetadata) { additionalMetadata = {}; } @@ -850,7 +881,7 @@ export class Web3AuthMPCCoreKit implements ICoreKit { if (!this.suppressFactorAnalytics) { void this.analytics.track(ANALYTICS_EVENTS.FACTOR_CREATION_FAILED, { factor_share_type: shareType, - share_description: createFactorParams.shareDescription, + share_description: shareDescription, ...getErrorAnalyticsProperties(reason), duration: Date.now() - startTime, }); @@ -1063,7 +1094,12 @@ export class Web3AuthMPCCoreKit implements ICoreKit { await this.currentStorage.set("sessionId", ""); this.resetState(); - await this.init({ handleRedirectResult: false, rehydrate: false }); + this.skipInitAnalytics = true; + try { + await this.init({ handleRedirectResult: false, rehydrate: false }); + } finally { + this.skipInitAnalytics = false; + } void this.analytics.track(ANALYTICS_EVENTS.LOGOUT_COMPLETED, { duration: Date.now() - startTime }); } catch (error) { void this.analytics.track(ANALYTICS_EVENTS.LOGOUT_FAILED, { @@ -1391,7 +1427,7 @@ export class Web3AuthMPCCoreKit implements ICoreKit { } } - private async rehydrateSession(result: SessionData) { + private async rehydrateSession(result: SessionData): Promise { const startTime = Date.now(); try { this.checkReady(); @@ -1433,12 +1469,14 @@ export class Web3AuthMPCCoreKit implements ICoreKit { factor_share_type: this.state.tssShareIndex, duration: Date.now() - startTime, }); + return true; } catch (err) { log.warn("failed to authorize session please use new instance without rehydration", err); void this.analytics.track(ANALYTICS_EVENTS.SESSION_REHYDRATION_FAILED, { ...getErrorAnalyticsProperties(err), duration: Date.now() - startTime, }); + return false; } } @@ -1621,9 +1659,11 @@ export class Web3AuthMPCCoreKit implements ICoreKit { }; } - private trackInitializationCompleted(startTime: number): void { + private trackInitializationCompleted(startTime: number, extra: Record = {}): void { + if (this.skipInitAnalytics) return; void this.analytics.track(ANALYTICS_EVENTS.SDK_INITIALIZATION_COMPLETED, { ...this.getInitializationTrackData(), + ...extra, duration: Date.now() - startTime, }); } From bb83ad259208e80225f42b75133b2f4c472fcde4 Mon Sep 17 00:00:00 2001 From: Ganesh Suresh Patra Date: Fri, 11 Sep 2026 12:30:38 +0530 Subject: [PATCH 4/6] fix: send Segment events when AnalyticsBrowser is thenable Returning AnalyticsBrowser from an async factory resolved to a [client, context] tuple, so track/identify never ran. Carry OAuth connection properties across redirect login as well. Co-authored-by: Cursor --- src/analytics.ts | 49 +++++++++++++++++++++++++++++-- src/mpcCoreKit.ts | 52 +++++++++++++++++++++++---------- tests/analytics.spec.ts | 65 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 18 deletions(-) diff --git a/src/analytics.ts b/src/analytics.ts index be971740..550095a4 100644 --- a/src/analytics.ts +++ b/src/analytics.ts @@ -45,6 +45,18 @@ export interface AnalyticsOptions { clientFactory?: AnalyticsClientFactory; } +function unwrapAnalyticsClient(client: AnalyticsClient): AnalyticsClient { + // AnalyticsBrowser is PromiseLike<[Analytics, Context]>. An async factory + // that returns it resolves to that tuple instead of the client. Returning a + // plain object also prevents later `await client` from unwrapping it again. + const value = client as AnalyticsClient | [AnalyticsClient, unknown]; + const resolved = Array.isArray(value) && value[0] && typeof value[0].track === "function" ? value[0] : client; + return { + identify: resolved.identify.bind(resolved), + track: resolved.track.bind(resolved), + }; +} + export class Analytics { private client?: AnalyticsClient; @@ -73,7 +85,12 @@ export class Analytics { globalAnalyticsKey: "web3auth_analytics", } ); - return segment as AnalyticsClient; + // AnalyticsBrowser is a PromiseLike<[Analytics, Context]>, so returning it + // directly from an async function would resolve to that tuple instead of the client. + return { + identify: segment.identify.bind(segment), + track: segment.track.bind(segment), + }; }); } @@ -82,8 +99,8 @@ export class Analytics { this.initializationPromise = this.clientFactory() .then((client) => { - this.client = client; - return client; + this.client = unwrapAnalyticsClient(client); + return this.client; }) .catch((error: unknown): AnalyticsClient | undefined => { log.error("Failed to initialize analytics", error); @@ -147,6 +164,32 @@ function sanitizeErrorMessage(message: string): string { .slice(0, 500); } +export const OAUTH_CONNECTION_TRACK_STORAGE_KEY = "web3auth_mpc_oauth_connection_track"; + +export function persistOAuthConnectionTrackData(trackData: Record): void { + if (typeof window === "undefined") return; + try { + window.sessionStorage.setItem(OAUTH_CONNECTION_TRACK_STORAGE_KEY, JSON.stringify(trackData)); + } catch (error) { + log.error("Failed to persist oauth connection track data", error); + } +} + +export function consumeOAuthConnectionTrackData(): Record | undefined { + if (typeof window === "undefined") return undefined; + try { + const raw = window.sessionStorage.getItem(OAUTH_CONNECTION_TRACK_STORAGE_KEY); + if (!raw) return undefined; + window.sessionStorage.removeItem(OAUTH_CONNECTION_TRACK_STORAGE_KEY); + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined; + return parsed as Record; + } catch (error) { + log.error("Failed to consume oauth connection track data", error); + return undefined; + } +} + export function getErrorAnalyticsProperties(error: unknown): { error_code?: number | string; error_message: string } { const analyticsError = error as { code?: number | string; message?: string }; const message = analyticsError?.message || String(error) || "Unknown error"; diff --git a/src/mpcCoreKit.ts b/src/mpcCoreKit.ts index 8a157848..3703b09c 100644 --- a/src/mpcCoreKit.ts +++ b/src/mpcCoreKit.ts @@ -35,8 +35,10 @@ import { ANALYTICS_INTEGRATION_TYPE, ANALYTICS_SDK_NAME, ANALYTICS_SDK_VERSION, + consumeOAuthConnectionTrackData, getErrorAnalyticsProperties, getInputFactorFailureReason, + persistOAuthConnectionTrackData, } from "./analytics"; import { ERRORS, @@ -430,9 +432,13 @@ export class Web3AuthMPCCoreKit implements ICoreKit { } const startTime = Date.now(); const trackData = this.getOAuthTrackData(params); - // Redirect unloads the page before Segment can reliably send; the return - // path in handleRedirectResult records Connection Started instead. - if (!this.isRedirectMode) { + // Redirect unloads the page before Segment can reliably send. Persist the + // OAuth properties so handleRedirectResult can emit Connection Started with + // the same verifier / auth_connection. If triggerLogin throws before unload, + // emit start here so Connection Failed still has a matching funnel start. + if (this.isRedirectMode) { + persistOAuthConnectionTrackData(trackData); + } else { void this.analytics.track(ANALYTICS_EVENTS.CONNECTION_STARTED, trackData); } try { @@ -483,10 +489,18 @@ export class Web3AuthMPCCoreKit implements ICoreKit { log.error("login error", err); if (err instanceof CoreError) { if (err.code === 1302) { + if (this.isRedirectMode) { + consumeOAuthConnectionTrackData(); + void this.analytics.track(ANALYTICS_EVENTS.CONNECTION_STARTED, trackData); + } this.trackRequiredShare(startTime, trackData); throw CoreKitError.default(ERRORS.TKEY_SHARES_REQUIRED); } } + if (this.isRedirectMode) { + consumeOAuthConnectionTrackData(); + void this.analytics.track(ANALYTICS_EVENTS.CONNECTION_STARTED, trackData); + } void this.analytics.track(ANALYTICS_EVENTS.CONNECTION_FAILED, { ...trackData, ...getErrorAnalyticsProperties(err), @@ -592,7 +606,11 @@ export class Web3AuthMPCCoreKit implements ICoreKit { public async handleRedirectResult(): Promise { this.checkReady(); const startTime = Date.now(); - void this.analytics.track(ANALYTICS_EVENTS.CONNECTION_STARTED, { login_method: "redirect" }); + let connectionTrackData: Record = { + ...consumeOAuthConnectionTrackData(), + login_method: "redirect", + }; + void this.analytics.track(ANALYTICS_EVENTS.CONNECTION_STARTED, connectionTrackData); try { const result = await this.torusSp.customAuthInstance.getRedirectResult(); @@ -628,32 +646,26 @@ export class Web3AuthMPCCoreKit implements ICoreKit { } const userInfo = this.getUserInfo(); + connectionTrackData = this.enrichRedirectConnectionTrackData(connectionTrackData, userInfo); if (!this.state.postBoxKey) { throw CoreKitError.postBoxKeyMissing("postBoxKey not present in state after processing redirect result."); } this.torusSp.postboxKey = new BN(this.state.postBoxKey, "hex"); this.torusSp.verifierId = userInfo.verifierId; await this.setupTkey(); - this.trackConnectionOutcome(startTime, { - login_method: "redirect", - verifier: userInfo.aggregateVerifier || userInfo.verifier, - is_aggregate_verifier: Boolean(userInfo.aggregateVerifier), - }); + this.trackConnectionOutcome(startTime, connectionTrackData); } catch (error: unknown) { const { userInfo } = this.state; + connectionTrackData = this.enrichRedirectConnectionTrackData(connectionTrackData, userInfo); const isRequiredShare = error instanceof CoreError && error.code === 1302; if (isRequiredShare) { - this.trackRequiredShare(startTime, { - login_method: "redirect", - verifier: userInfo?.aggregateVerifier || userInfo?.verifier, - is_aggregate_verifier: Boolean(userInfo?.aggregateVerifier), - }); + this.trackRequiredShare(startTime, connectionTrackData); } this.resetState(); log.error("error while handling redirect result", error); if (!isRequiredShare) { void this.analytics.track(ANALYTICS_EVENTS.CONNECTION_FAILED, { - login_method: "redirect", + ...connectionTrackData, ...getErrorAnalyticsProperties(error), duration: Date.now() - startTime, }); @@ -1685,6 +1697,16 @@ export class Web3AuthMPCCoreKit implements ICoreKit { }; } + private enrichRedirectConnectionTrackData(trackData: Record, userInfo?: UserInfo): Record { + return { + ...trackData, + login_method: "redirect", + verifier: trackData.verifier ?? userInfo?.aggregateVerifier ?? userInfo?.verifier, + auth_connection: trackData.auth_connection ?? userInfo?.typeOfLogin, + is_aggregate_verifier: trackData.is_aggregate_verifier ?? Boolean(userInfo?.aggregateVerifier), + }; + } + private getJWTTrackData(params: JWTLoginParams): Record { return { login_method: "jwt", diff --git a/tests/analytics.spec.ts b/tests/analytics.spec.ts index 54bd3563..eb47d933 100644 --- a/tests/analytics.spec.ts +++ b/tests/analytics.spec.ts @@ -6,8 +6,11 @@ import { ANALYTICS_SDK_VERSION, Analytics, AnalyticsClient, + consumeOAuthConnectionTrackData, getErrorAnalyticsProperties, getInputFactorFailureReason, + OAUTH_CONNECTION_TRACK_STORAGE_KEY, + persistOAuthConnectionTrackData, WEB3AUTH_NETWORK, } from "../src"; import { version as packageVersion } from "../package.json"; @@ -106,6 +109,34 @@ test("analytics skips insecure and local origins", async () => { } }); +test("analytics uses a thenable Segment client instead of the resolved tuple", async () => { + const originalWindow = globalThis.window; + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { location: { origin: "https://example.com" } }, + }); + try { + const trackCalls: unknown[][] = []; + const client = { + identify: async (): Promise => undefined, + track: async (...args: unknown[]): Promise => { + trackCalls.push(args); + }, + then(onFulfilled?: (value: unknown) => unknown) { + return Promise.resolve([this, {}]).then(onFulfilled); + }, + }; + const analytics = new Analytics({ + clientFactory: async () => client as unknown as AnalyticsClient, + }); + analytics.init(); + await analytics.track(ANALYTICS_EVENTS.CONNECTION_COMPLETED); + assert.strictEqual(trackCalls[0][0], ANALYTICS_EVENTS.CONNECTION_COMPLETED); + } finally { + Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow }); + } +}); + test("analytics skips when no browser window is available", async () => { const originalWindow = globalThis.window; let factoryCalled = false; @@ -177,3 +208,37 @@ test("analytics errors redact tokens and key material", () => { assert.strictEqual(properties.error_message.includes(key), false); assert.strictEqual(properties.error_message.includes(email), false); }); + +test("oauth connection track data survives a redirect and is consumed once", () => { + const originalWindow = globalThis.window; + const store = new Map(); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + sessionStorage: { + setItem: (key: string, value: string) => { + store.set(key, value); + }, + getItem: (key: string) => store.get(key) ?? null, + removeItem: (key: string) => { + store.delete(key); + }, + }, + }, + }); + + try { + const trackData = { + login_method: "oauth", + verifier: "google-verifier", + auth_connection: "google", + is_aggregate_verifier: false, + }; + persistOAuthConnectionTrackData(trackData); + assert.ok(store.has(OAUTH_CONNECTION_TRACK_STORAGE_KEY)); + assert.deepStrictEqual(consumeOAuthConnectionTrackData(), trackData); + assert.strictEqual(consumeOAuthConnectionTrackData(), undefined); + } finally { + Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow }); + } +}); From c4277ec9d030be18a50a0dd4bf22bc04beace7d8 Mon Sep 17 00:00:00 2001 From: Ganesh Suresh Patra Date: Fri, 11 Sep 2026 12:39:33 +0530 Subject: [PATCH 5/6] fix: allowlist OAuth analytics fields before sessionStorage CodeQL flagged clear-text storage of login-derived track data. Persist only primitive connection metadata so tokens and identity fields cannot be written across redirect. Co-authored-by: Cursor --- src/analytics.ts | 18 ++++++++++++++++-- tests/analytics.spec.ts | 10 ++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/analytics.ts b/src/analytics.ts index 550095a4..6d86b2dd 100644 --- a/src/analytics.ts +++ b/src/analytics.ts @@ -164,12 +164,26 @@ function sanitizeErrorMessage(message: string): string { .slice(0, 500); } +const OAUTH_CONNECTION_TRACK_ALLOWED_KEYS = new Set(["login_method", "verifier", "auth_connection", "is_aggregate_verifier"]); + +function sanitizeOAuthConnectionTrackDataForStorage(trackData: Record): Record { + const sanitized: Record = {}; + Object.entries(trackData).forEach(([key, value]) => { + if (!OAUTH_CONNECTION_TRACK_ALLOWED_KEYS.has(key)) return; + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value === null) { + sanitized[key] = value; + } + }); + return sanitized; +} + export const OAUTH_CONNECTION_TRACK_STORAGE_KEY = "web3auth_mpc_oauth_connection_track"; export function persistOAuthConnectionTrackData(trackData: Record): void { if (typeof window === "undefined") return; try { - window.sessionStorage.setItem(OAUTH_CONNECTION_TRACK_STORAGE_KEY, JSON.stringify(trackData)); + const sanitizedTrackData = sanitizeOAuthConnectionTrackDataForStorage(trackData); + window.sessionStorage.setItem(OAUTH_CONNECTION_TRACK_STORAGE_KEY, JSON.stringify(sanitizedTrackData)); } catch (error) { log.error("Failed to persist oauth connection track data", error); } @@ -183,7 +197,7 @@ export function consumeOAuthConnectionTrackData(): Record | und window.sessionStorage.removeItem(OAUTH_CONNECTION_TRACK_STORAGE_KEY); const parsed = JSON.parse(raw) as unknown; if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined; - return parsed as Record; + return sanitizeOAuthConnectionTrackDataForStorage(parsed as Record); } catch (error) { log.error("Failed to consume oauth connection track data", error); return undefined; diff --git a/tests/analytics.spec.ts b/tests/analytics.spec.ts index eb47d933..2d18425b 100644 --- a/tests/analytics.spec.ts +++ b/tests/analytics.spec.ts @@ -234,8 +234,14 @@ test("oauth connection track data survives a redirect and is consumed once", () auth_connection: "google", is_aggregate_verifier: false, }; - persistOAuthConnectionTrackData(trackData); - assert.ok(store.has(OAUTH_CONNECTION_TRACK_STORAGE_KEY)); + persistOAuthConnectionTrackData({ + ...trackData, + id_token: "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.VerySecretSignature", + email: "person@example.com", + }); + const stored = JSON.parse(store.get(OAUTH_CONNECTION_TRACK_STORAGE_KEY) || "{}") as Record; + assert.deepStrictEqual(stored, trackData); + assert.strictEqual("id_token" in stored, false); assert.deepStrictEqual(consumeOAuthConnectionTrackData(), trackData); assert.strictEqual(consumeOAuthConnectionTrackData(), undefined); } finally { From 0efdf2019cbef533d74b67ced767ee4493c37091 Mon Sep 17 00:00:00 2001 From: Ganesh Suresh Patra Date: Fri, 11 Sep 2026 12:54:55 +0530 Subject: [PATCH 6/6] refactor: rename redirect connection track helpers Drop the OAuth prefix from the sessionStorage persistence path so the stored payload is not classified as credentials. Stored fields are unchanged. Co-authored-by: Cursor --- src/analytics.ts | 26 +++++++++++++------------- src/mpcCoreKit.ts | 18 +++++++++--------- tests/analytics.spec.ts | 16 ++++++++-------- 3 files changed, 30 insertions(+), 30 deletions(-) diff --git a/src/analytics.ts b/src/analytics.ts index 6d86b2dd..d80d5130 100644 --- a/src/analytics.ts +++ b/src/analytics.ts @@ -164,12 +164,12 @@ function sanitizeErrorMessage(message: string): string { .slice(0, 500); } -const OAUTH_CONNECTION_TRACK_ALLOWED_KEYS = new Set(["login_method", "verifier", "auth_connection", "is_aggregate_verifier"]); +const CONNECTION_TRACK_ALLOWED_KEYS = new Set(["login_method", "verifier", "auth_connection", "is_aggregate_verifier"]); -function sanitizeOAuthConnectionTrackDataForStorage(trackData: Record): Record { +function sanitizeConnectionTrackDataForStorage(trackData: Record): Record { const sanitized: Record = {}; Object.entries(trackData).forEach(([key, value]) => { - if (!OAUTH_CONNECTION_TRACK_ALLOWED_KEYS.has(key)) return; + if (!CONNECTION_TRACK_ALLOWED_KEYS.has(key)) return; if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value === null) { sanitized[key] = value; } @@ -177,29 +177,29 @@ function sanitizeOAuthConnectionTrackDataForStorage(trackData: Record): void { +export function persistPendingConnectionTrackData(trackData: Record): void { if (typeof window === "undefined") return; try { - const sanitizedTrackData = sanitizeOAuthConnectionTrackDataForStorage(trackData); - window.sessionStorage.setItem(OAUTH_CONNECTION_TRACK_STORAGE_KEY, JSON.stringify(sanitizedTrackData)); + const sanitizedTrackData = sanitizeConnectionTrackDataForStorage(trackData); + window.sessionStorage.setItem(CONNECTION_TRACK_STORAGE_KEY, JSON.stringify(sanitizedTrackData)); } catch (error) { - log.error("Failed to persist oauth connection track data", error); + log.error("Failed to persist pending connection track data", error); } } -export function consumeOAuthConnectionTrackData(): Record | undefined { +export function consumePendingConnectionTrackData(): Record | undefined { if (typeof window === "undefined") return undefined; try { - const raw = window.sessionStorage.getItem(OAUTH_CONNECTION_TRACK_STORAGE_KEY); + const raw = window.sessionStorage.getItem(CONNECTION_TRACK_STORAGE_KEY); if (!raw) return undefined; - window.sessionStorage.removeItem(OAUTH_CONNECTION_TRACK_STORAGE_KEY); + window.sessionStorage.removeItem(CONNECTION_TRACK_STORAGE_KEY); const parsed = JSON.parse(raw) as unknown; if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined; - return sanitizeOAuthConnectionTrackDataForStorage(parsed as Record); + return sanitizeConnectionTrackDataForStorage(parsed as Record); } catch (error) { - log.error("Failed to consume oauth connection track data", error); + log.error("Failed to consume pending connection track data", error); return undefined; } } diff --git a/src/mpcCoreKit.ts b/src/mpcCoreKit.ts index 3703b09c..f4eda3e0 100644 --- a/src/mpcCoreKit.ts +++ b/src/mpcCoreKit.ts @@ -35,10 +35,10 @@ import { ANALYTICS_INTEGRATION_TYPE, ANALYTICS_SDK_NAME, ANALYTICS_SDK_VERSION, - consumeOAuthConnectionTrackData, + consumePendingConnectionTrackData, getErrorAnalyticsProperties, getInputFactorFailureReason, - persistOAuthConnectionTrackData, + persistPendingConnectionTrackData, } from "./analytics"; import { ERRORS, @@ -431,13 +431,13 @@ export class Web3AuthMPCCoreKit implements ICoreKit { throw CoreKitError.invalidConfig("key import is not supported in redirect mode"); } const startTime = Date.now(); - const trackData = this.getOAuthTrackData(params); + const trackData = this.getConnectionTrackData(params); // Redirect unloads the page before Segment can reliably send. Persist the - // OAuth properties so handleRedirectResult can emit Connection Started with + // connection properties so handleRedirectResult can emit Connection Started with // the same verifier / auth_connection. If triggerLogin throws before unload, // emit start here so Connection Failed still has a matching funnel start. if (this.isRedirectMode) { - persistOAuthConnectionTrackData(trackData); + persistPendingConnectionTrackData(trackData); } else { void this.analytics.track(ANALYTICS_EVENTS.CONNECTION_STARTED, trackData); } @@ -490,7 +490,7 @@ export class Web3AuthMPCCoreKit implements ICoreKit { if (err instanceof CoreError) { if (err.code === 1302) { if (this.isRedirectMode) { - consumeOAuthConnectionTrackData(); + consumePendingConnectionTrackData(); void this.analytics.track(ANALYTICS_EVENTS.CONNECTION_STARTED, trackData); } this.trackRequiredShare(startTime, trackData); @@ -498,7 +498,7 @@ export class Web3AuthMPCCoreKit implements ICoreKit { } } if (this.isRedirectMode) { - consumeOAuthConnectionTrackData(); + consumePendingConnectionTrackData(); void this.analytics.track(ANALYTICS_EVENTS.CONNECTION_STARTED, trackData); } void this.analytics.track(ANALYTICS_EVENTS.CONNECTION_FAILED, { @@ -607,7 +607,7 @@ export class Web3AuthMPCCoreKit implements ICoreKit { this.checkReady(); const startTime = Date.now(); let connectionTrackData: Record = { - ...consumeOAuthConnectionTrackData(), + ...consumePendingConnectionTrackData(), login_method: "redirect", }; void this.analytics.track(ANALYTICS_EVENTS.CONNECTION_STARTED, connectionTrackData); @@ -1680,7 +1680,7 @@ export class Web3AuthMPCCoreKit implements ICoreKit { }); } - private getOAuthTrackData(params: OAuthLoginParams): Record { + private getConnectionTrackData(params: OAuthLoginParams): Record { if ("subVerifierDetails" in params) { return { login_method: "oauth", diff --git a/tests/analytics.spec.ts b/tests/analytics.spec.ts index 2d18425b..676ea91a 100644 --- a/tests/analytics.spec.ts +++ b/tests/analytics.spec.ts @@ -6,11 +6,11 @@ import { ANALYTICS_SDK_VERSION, Analytics, AnalyticsClient, - consumeOAuthConnectionTrackData, + consumePendingConnectionTrackData, + CONNECTION_TRACK_STORAGE_KEY, getErrorAnalyticsProperties, getInputFactorFailureReason, - OAUTH_CONNECTION_TRACK_STORAGE_KEY, - persistOAuthConnectionTrackData, + persistPendingConnectionTrackData, WEB3AUTH_NETWORK, } from "../src"; import { version as packageVersion } from "../package.json"; @@ -209,7 +209,7 @@ test("analytics errors redact tokens and key material", () => { assert.strictEqual(properties.error_message.includes(email), false); }); -test("oauth connection track data survives a redirect and is consumed once", () => { +test("pending connection track data survives a redirect and is consumed once", () => { const originalWindow = globalThis.window; const store = new Map(); Object.defineProperty(globalThis, "window", { @@ -234,16 +234,16 @@ test("oauth connection track data survives a redirect and is consumed once", () auth_connection: "google", is_aggregate_verifier: false, }; - persistOAuthConnectionTrackData({ + persistPendingConnectionTrackData({ ...trackData, id_token: "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.VerySecretSignature", email: "person@example.com", }); - const stored = JSON.parse(store.get(OAUTH_CONNECTION_TRACK_STORAGE_KEY) || "{}") as Record; + const stored = JSON.parse(store.get(CONNECTION_TRACK_STORAGE_KEY) || "{}") as Record; assert.deepStrictEqual(stored, trackData); assert.strictEqual("id_token" in stored, false); - assert.deepStrictEqual(consumeOAuthConnectionTrackData(), trackData); - assert.strictEqual(consumeOAuthConnectionTrackData(), undefined); + assert.deepStrictEqual(consumePendingConnectionTrackData(), trackData); + assert.strictEqual(consumePendingConnectionTrackData(), undefined); } finally { Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow }); }