Skip to content

Commit 5df4d83

Browse files
feat: capture MaxMind tracking token on session metadata at signup
1 parent 7e4965b commit 5df4d83

15 files changed

Lines changed: 12091 additions & 11897 deletions

.gitignore

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
1-
.DS_Store
2-
node_modules
3-
.turbo
4-
*.log
5-
.next
6-
dist
7-
dist-ssr
8-
*.local
9-
.env
10-
server/dist
11-
public/dist
12-
.vscode
13-
.idea
14-
.vercel
15-
.env*.local
16-
/blob-report/
17-
/out
18-
/docker
19-
/machinekey
20-
21-
.cursor
1+
.DS_Store
2+
node_modules
3+
.turbo
4+
*.log
5+
.next
6+
dist
7+
dist-ssr
8+
*.local
9+
server/dist
10+
public/dist
11+
.vscode
12+
.idea
13+
.vercel
14+
.env*.local
15+
/blob-report/
16+
/out
17+
/docker
18+
/machinekey
19+
20+
.cursor
21+
config.bat

apps/login/next-env-vars.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,12 @@ declare namespace NodeJS {
4444
* Optional: the Fathom analytics id
4545
*/
4646
FATHOM_ID?: string;
47+
48+
/**
49+
* Optional: the MaxMind minFraud account id used to load the
50+
* device-tracking JavaScript snippet at signup. When unset, the
51+
* tracker component is not rendered and no token is collected.
52+
*/
53+
NEXT_PUBLIC_MAXMIND_ACCOUNT_ID?: string;
4754
}
4855
}

apps/login/package.json

Lines changed: 77 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,77 +1,77 @@
1-
{
2-
"name": "@zitadel/login",
3-
"private": true,
4-
"type": "module",
5-
"scripts": {
6-
"dev": "pnpm next dev --turbopack",
7-
"test:unit": "pnpm vitest",
8-
"test:unit:standalone": "pnpm test:unit",
9-
"test:unit:watch": "pnpm test:unit --watch",
10-
"lint": "pnpm exec eslint . && pnpm exec prettier --check .",
11-
"lint:fix": "pnpm exec prettier --write .",
12-
"lint-staged": "lint-staged",
13-
"build": "pnpm exec next build",
14-
"build:login:standalone": "NEXT_PUBLIC_BASE_PATH=/ui/v2/login NEXT_OUTPUT_MODE=standalone pnpm build",
15-
"start": "pnpm build && pnpm exec next start",
16-
"start:built": "pnpm exec next start",
17-
"clean": "pnpm mock:stop && rm -rf .turbo && rm -rf node_modules && rm -rf .next"
18-
},
19-
"git": {
20-
"pre-commit": "lint-staged"
21-
},
22-
"lint-staged": {
23-
"*": "prettier --write --ignore-unknown"
24-
},
25-
"dependencies": {
26-
"@headlessui/react": "^2.1.9",
27-
"@heroicons/react": "2.1.3",
28-
"@sentry/nextjs": "^10.34.0",
29-
"@tailwindcss/forms": "0.5.7",
30-
"@vercel/analytics": "^1.2.2",
31-
"@zitadel/client": "workspace:*",
32-
"@zitadel/proto": "workspace:*",
33-
"clsx": "1.2.1",
34-
"copy-to-clipboard": "^3.3.3",
35-
"deepmerge": "^4.3.1",
36-
"fathom-client": "^3.7.2",
37-
"lucide-react": "0.469.0",
38-
"moment": "^2.29.4",
39-
"next": "16.2.3",
40-
"next-intl": "^4.9.1",
41-
"next-themes": "^0.4.6",
42-
"nice-grpc": "2.0.1",
43-
"qrcode.react": "^3.1.0",
44-
"react": "19.2.3",
45-
"react-dom": "19.2.3",
46-
"react-hook-form": "7.39.5",
47-
"tinycolor2": "1.4.2",
48-
"uuid": "^11.1.0"
49-
},
50-
"devDependencies": {
51-
"@bufbuild/buf": "^1.53.0",
52-
"@testing-library/jest-dom": "^6.6.3",
53-
"@testing-library/react": "^16.3.0",
54-
"@types/ms": "2.1.0",
55-
"@types/node": "^22.14.1",
56-
"@types/react": "19.2.3",
57-
"@types/react-dom": "19.2.3",
58-
"@types/tinycolor2": "1.4.3",
59-
"@types/uuid": "^10.0.0",
60-
"@vercel/git-hooks": "1.0.0",
61-
"@zitadel/eslint-config": "workspace:*",
62-
"@zitadel/prettier-config": "workspace:*",
63-
"@zitadel/tailwind-config": "workspace:*",
64-
"@zitadel/tsconfig": "workspace:*",
65-
"autoprefixer": "10.4.21",
66-
"grpc-tools": "1.13.0",
67-
"jsdom": "^26.1.0",
68-
"lint-staged": "15.5.1",
69-
"make-dir-cli": "4.0.0",
70-
"postcss": "8.5.3",
71-
"prettier-plugin-tailwindcss": "0.6.11",
72-
"sass": "^1.87.0",
73-
"tailwindcss": "3.4.14",
74-
"ts-proto": "^2.7.0",
75-
"typescript": "^5.8.3"
76-
}
77-
}
1+
{
2+
"name": "@zitadel/login",
3+
"private": true,
4+
"type": "module",
5+
"scripts": {
6+
"dev": "pnpm next dev --turbopack",
7+
"test:unit": "pnpm vitest",
8+
"test:unit:standalone": "pnpm test:unit",
9+
"test:unit:watch": "pnpm test:unit --watch",
10+
"lint": "pnpm exec eslint . && pnpm exec prettier --check .",
11+
"lint:fix": "pnpm exec prettier --write .",
12+
"lint-staged": "lint-staged",
13+
"build": "pnpm exec next build",
14+
"build:login:standalone": "NEXT_PUBLIC_BASE_PATH=/ui/v2/login NEXT_OUTPUT_MODE=standalone pnpm build",
15+
"start": "pnpm build && pnpm exec next start",
16+
"start:built": "pnpm exec next start",
17+
"clean": "pnpm mock:stop && rm -rf .turbo && rm -rf node_modules && rm -rf .next"
18+
},
19+
"git": {
20+
"pre-commit": "lint-staged"
21+
},
22+
"lint-staged": {
23+
"*": "prettier --write --ignore-unknown"
24+
},
25+
"dependencies": {
26+
"@headlessui/react": "^2.1.9",
27+
"@heroicons/react": "2.1.3",
28+
"@sentry/nextjs": "^10.34.0",
29+
"@tailwindcss/forms": "0.5.7",
30+
"@vercel/analytics": "^1.2.2",
31+
"@zitadel/client": "workspace:*",
32+
"@zitadel/proto": "workspace:*",
33+
"clsx": "1.2.1",
34+
"copy-to-clipboard": "^3.3.3",
35+
"deepmerge": "^4.3.1",
36+
"fathom-client": "^3.7.2",
37+
"lucide-react": "0.469.0",
38+
"moment": "^2.29.4",
39+
"next": "16.2.3",
40+
"next-intl": "^4.9.1",
41+
"next-themes": "^0.4.6",
42+
"nice-grpc": "2.0.1",
43+
"qrcode.react": "^3.1.0",
44+
"react": "19.2.3",
45+
"react-dom": "19.2.3",
46+
"react-hook-form": "7.39.5",
47+
"tinycolor2": "1.4.2",
48+
"uuid": "^11.1.0"
49+
},
50+
"devDependencies": {
51+
"@bufbuild/buf": "^1.53.0",
52+
"@testing-library/jest-dom": "^6.6.3",
53+
"@testing-library/react": "^16.3.0",
54+
"@types/ms": "2.1.0",
55+
"@types/node": "^22.14.1",
56+
"@types/react": "19.2.3",
57+
"@types/react-dom": "19.2.3",
58+
"@types/tinycolor2": "1.4.3",
59+
"@types/uuid": "^10.0.0",
60+
"@vercel/git-hooks": "1.0.0",
61+
"@zitadel/eslint-config": "workspace:*",
62+
"@zitadel/prettier-config": "workspace:*",
63+
"@zitadel/tailwind-config": "workspace:*",
64+
"@zitadel/tsconfig": "workspace:*",
65+
"autoprefixer": "10.4.21",
66+
"grpc-tools": "1.13.0",
67+
"jsdom": "^26.1.0",
68+
"lint-staged": "15.5.1",
69+
"make-dir-cli": "4.0.0",
70+
"postcss": "8.5.3",
71+
"prettier-plugin-tailwindcss": "0.6.11",
72+
"sass": "^1.87.0",
73+
"tailwindcss": "3.4.14",
74+
"ts-proto": "^2.7.0",
75+
"typescript": "^5.8.3"
76+
}
77+
}

apps/login/src/app/(main)/layout.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { FathomAnalytics } from "@/components/fathom/fathom";
77
import { LanguageProvider } from "@/components/language-provider";
88
import { Loader } from "@/components/loader";
99
import MarkerIoEmbed from "@/components/markerio/markerio";
10+
import { MaxMindTracker } from "@/components/maxmind/maxmind-tracker";
1011
import { ThemeProvider } from "@/components/theme-provider";
1112
import { SITE_CONFIG } from "@/config/site";
1213
import { alliance, canelaText, frontliner } from "@/lib/fonts/fonts";
@@ -64,6 +65,12 @@ export default async function RootLayout({
6465
</ThemeProvider>
6566
<FathomAnalytics />
6667

68+
{process.env.NEXT_PUBLIC_MAXMIND_ACCOUNT_ID && (
69+
<MaxMindTracker
70+
accountId={process.env.NEXT_PUBLIC_MAXMIND_ACCOUNT_ID}
71+
/>
72+
)}
73+
6774
{process.env.MARKER_IO_PROJECT_ID && (
6875
<MarkerIoEmbed projectId={process.env.MARKER_IO_PROJECT_ID} />
6976
)}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"use client";
2+
3+
import { useEffect } from "react";
4+
5+
/**
6+
* sessionStorage key under which we stash the MaxMind device-tracking token
7+
* captured by device.js. The register form reads it just before submitting so
8+
* the value can be forwarded to Zitadel as session metadata.
9+
*/
10+
export const MAXMIND_TOKEN_STORAGE_KEY = "datum.maxmind.trackingToken";
11+
12+
/**
13+
* Cookie set by MaxMind's device.js after a successful fingerprint exchange.
14+
* The cookie value is the tracking token; we mirror it into sessionStorage so
15+
* we don't have to parse document.cookie at submit time.
16+
*/
17+
const MAXMIND_COOKIE_NAME = "__mmapiwsid";
18+
19+
function readMaxMindCookie(): string {
20+
if (typeof document === "undefined") return "";
21+
const prefix = `${MAXMIND_COOKIE_NAME}=`;
22+
for (const part of document.cookie.split(";")) {
23+
const trimmed = part.trim();
24+
if (trimmed.startsWith(prefix)) {
25+
return decodeURIComponent(trimmed.slice(prefix.length));
26+
}
27+
}
28+
return "";
29+
}
30+
31+
/**
32+
* MaxMindTracker loads the MaxMind minFraud device.js snippet on mount and
33+
* polls for the resulting __mmapiwsid cookie, mirroring it into
34+
* sessionStorage. Rendering is gated by NEXT_PUBLIC_MAXMIND_ACCOUNT_ID — if
35+
* unset the component is a no-op, so dev and preview deployments never
36+
* contact MaxMind.
37+
*/
38+
export function MaxMindTracker({ accountId }: { accountId: string }) {
39+
useEffect(() => {
40+
if (!accountId) return;
41+
if (typeof window === "undefined") return;
42+
43+
const w = window as unknown as {
44+
__mmapiws?: { accountId?: string };
45+
};
46+
w.__mmapiws = w.__mmapiws || {};
47+
w.__mmapiws.accountId = accountId;
48+
49+
// Load device.js (idempotent — guard against StrictMode double-mount).
50+
if (!document.querySelector('script[data-maxmind="device"]')) {
51+
const script = document.createElement("script");
52+
script.src = "https://device.maxmind.com/js/device.js";
53+
script.async = true;
54+
script.dataset.maxmind = "device";
55+
document.body.appendChild(script);
56+
}
57+
58+
// Poll briefly for the tracking-token cookie. device.js sets it after
59+
// an async fingerprint exchange, typically within a few hundred ms.
60+
let attempts = 0;
61+
const maxAttempts = 30; // ~6 seconds at 200ms cadence
62+
const handle = window.setInterval(() => {
63+
attempts++;
64+
const token = readMaxMindCookie();
65+
if (token) {
66+
try {
67+
window.sessionStorage.setItem(MAXMIND_TOKEN_STORAGE_KEY, token);
68+
} catch {
69+
// sessionStorage may be disabled (private mode). Best-effort only.
70+
}
71+
window.clearInterval(handle);
72+
return;
73+
}
74+
if (attempts >= maxAttempts) {
75+
window.clearInterval(handle);
76+
}
77+
}, 200);
78+
79+
return () => window.clearInterval(handle);
80+
}, [accountId]);
81+
82+
return null;
83+
}
84+
85+
/**
86+
* Returns the MaxMind device-tracking token captured during the session, or
87+
* undefined when none is available (sessionStorage blocked, MaxMind script
88+
* not yet finished, or NEXT_PUBLIC_MAXMIND_ACCOUNT_ID unset).
89+
*/
90+
export function readMaxMindTrackingToken(): string | undefined {
91+
if (typeof window === "undefined") return undefined;
92+
try {
93+
const value = window.sessionStorage.getItem(MAXMIND_TOKEN_STORAGE_KEY);
94+
return value || undefined;
95+
} catch {
96+
return undefined;
97+
}
98+
}

apps/login/src/components/register-form-idp-incomplete.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Alert } from "./alert";
88
import { BackButton } from "./back-button";
99
import { Button, ButtonVariants } from "./button";
1010
import { TextInput } from "./input";
11+
import { readMaxMindTrackingToken } from "./maxmind/maxmind-tracker";
1112
import { Spinner } from "./spinner";
1213
import { Translated } from "./translated";
1314

@@ -71,6 +72,7 @@ export function RegisterFormIDPIncomplete({
7172
organization: organization,
7273
requestId: requestId,
7374
idpIntent: idpIntent,
75+
deviceTrackingToken: readMaxMindTrackingToken(),
7476
})
7577
.catch(() => {
7678
setError("Could not register user");

apps/login/src/components/register-form.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from "./authentication-method-radio";
1717
import { FormActions } from "./form-actions";
1818
import { TextInput } from "./input";
19+
import { readMaxMindTrackingToken } from "./maxmind/maxmind-tracker";
1920
import { Translated } from "./translated";
2021

2122
type Inputs =
@@ -68,6 +69,7 @@ export function RegisterForm({
6869
lastName: values.lastname,
6970
organization: organization,
7071
requestId: requestId,
72+
deviceTrackingToken: readMaxMindTrackingToken(),
7173
})
7274
.catch(() => {
7375
setError("Could not register user");

apps/login/src/components/set-register-password-form.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { FieldValues, useForm } from "react-hook-form";
1414
import { Alert } from "./alert";
1515
import { FormActions } from "./form-actions";
1616
import { TextInput } from "./input";
17+
import { readMaxMindTrackingToken } from "./maxmind/maxmind-tracker";
1718
import { PasswordComplexity } from "./password-complexity";
1819
import { Translated } from "./translated";
1920

@@ -64,6 +65,7 @@ export function SetRegisterPasswordForm({
6465
organization: organization,
6566
requestId: requestId,
6667
password: values.password,
68+
deviceTrackingToken: readMaxMindTrackingToken(),
6769
})
6870
.catch(() => {
6971
setError("Could not register user");

0 commit comments

Comments
 (0)