Skip to content

Commit 0c8f9f7

Browse files
feat: capture MaxMind tracking token at signup
Load MaxMind's device.js on every page under the (main) layout so the device fingerprint is collected for any visitor who lands on the auth UI. When the signup form is submitted, the resulting tracking token is attached to Zitadel as user metadata (key maxmind/tracking-token); auth-provider-zitadel mirrors it onto the milo User annotation and the fraud service forwards it to the existing minFraud query as device.tracking_token. Key features/changes: - New client component MaxMindTracker initialises window.__mmapiws, injects device.js once, and polls the __mmapiwsid cookie until the token is available, persisting it to sessionStorage so the value survives the /register -> /register/password route transition - Layout gates the tracker on NEXT_PUBLIC_MAXMIND_ACCOUNT_ID, mirroring the existing Fathom and Marker.io toggles, so dev and preview deployments never contact MaxMind - All three register forms (password, passkey-only, IDP-incomplete) read the token from sessionStorage and pass it through registerUser / registerUserAndLinkToIDP - addHumanUser accepts an optional metadata map and encodes entries as SetMetadataEntry on the Zitadel AddHumanUserRequest - Document NEXT_PUBLIC_MAXMIND_ACCOUNT_ID in next-env-vars.d.ts Token capture is best-effort: if device.js hasn't returned the cookie before submit, the user proceeds without it and the fraud check falls back to IP/email/UA signals only.
1 parent 7e4965b commit 0c8f9f7

8 files changed

Lines changed: 165 additions & 0 deletions

File tree

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/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 user 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");

apps/login/src/lib/server/register.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,27 @@ import { getNextUrl } from "../client";
2222
import { getServiceUrlFromHeaders } from "../service-url";
2323
import { checkEmailVerification } from "../verify-helper";
2424

25+
/**
26+
* Zitadel user-metadata key carrying the MaxMind minFraud device-tracking
27+
* token captured by the browser at signup. auth-provider-zitadel's
28+
* actions-server reads this key on user.human.added and mirrors it to a
29+
* milo User annotation so the fraud service can include it in the
30+
* minFraud request. Keep this constant in sync with the Go side.
31+
*/
32+
const MAXMIND_TRACKING_TOKEN_METADATA_KEY = "maxmind/tracking-token";
33+
2534
type RegisterUserCommand = {
2635
email: string;
2736
firstName: string;
2837
lastName: string;
2938
password?: string;
3039
organization: string;
3140
requestId?: string;
41+
/**
42+
* MaxMind device-tracking token from the browser, when available. Sent as
43+
* Zitadel user metadata and silently dropped if empty.
44+
*/
45+
deviceTrackingToken?: string;
3246
};
3347

3448
export type RegisterUserResponse = {
@@ -52,6 +66,11 @@ export async function registerUser(command: RegisterUserCommand) {
5266
lastName: command.lastName,
5367
password: command.password ? command.password : undefined,
5468
organization: command.organization,
69+
metadata: command.deviceTrackingToken
70+
? {
71+
[MAXMIND_TRACKING_TOKEN_METADATA_KEY]: command.deviceTrackingToken,
72+
}
73+
: undefined,
5574
});
5675

5776
if (!addResponse) {
@@ -156,6 +175,11 @@ type RegisterUserAndLinkToIDPommand = {
156175
idpUserId: string;
157176
idpId: string;
158177
idpUserName: string;
178+
/**
179+
* MaxMind device-tracking token from the browser, when available. Sent
180+
* as Zitadel user metadata in the same way as the password flow.
181+
*/
182+
deviceTrackingToken?: string;
159183
};
160184

161185
export type registerUserAndLinkToIDPResponse = {
@@ -180,6 +204,11 @@ export async function registerUserAndLinkToIDP(
180204
firstName: command.firstName,
181205
lastName: command.lastName,
182206
organization: command.organization,
207+
metadata: command.deviceTrackingToken
208+
? {
209+
[MAXMIND_TRACKING_TOKEN_METADATA_KEY]: command.deviceTrackingToken,
210+
}
211+
: undefined,
183212
});
184213

185214
if (!addResponse) {

apps/login/src/lib/zitadel.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,13 @@ export type AddHumanUserData = {
454454
email: string;
455455
password?: string;
456456
organization: string;
457+
/**
458+
* Optional Zitadel user metadata to attach at creation time. Keys/values
459+
* are stored on the Zitadel user; downstream (auth-provider-zitadel)
460+
* reads selected keys and mirrors them onto the milo User. Used today
461+
* for the MaxMind device-tracking token captured at signup.
462+
*/
463+
metadata?: Record<string, string>;
457464
};
458465

459466
export async function addHumanUser({
@@ -463,12 +470,22 @@ export async function addHumanUser({
463470
lastName,
464471
password,
465472
organization,
473+
metadata,
466474
}: AddHumanUserData) {
467475
const userService: Client<typeof UserService> = await createServiceForHost(
468476
UserService,
469477
serviceUrl,
470478
);
471479

480+
const metadataEntries = metadata
481+
? Object.entries(metadata)
482+
.filter(([, value]) => value)
483+
.map(([key, value]) => ({
484+
key,
485+
value: new TextEncoder().encode(value),
486+
}))
487+
: [];
488+
472489
let addHumanUserRequest: AddHumanUserRequest = create(
473490
AddHumanUserRequestSchema,
474491
{
@@ -484,6 +501,7 @@ export async function addHumanUser({
484501
passwordType: password
485502
? { case: "password", value: { password } }
486503
: undefined,
504+
metadata: metadataEntries.length > 0 ? metadataEntries : undefined,
487505
},
488506
);
489507

0 commit comments

Comments
 (0)