From 3168e91618fd52a84cbc4faa5b1f1615d97920f0 Mon Sep 17 00:00:00 2001 From: Matt Jenkinson <75292329+mattdjenkinson@users.noreply.github.com> Date: Mon, 11 May 2026 15:29:42 +0200 Subject: [PATCH] feat: capture MaxMind tracking token on session metadata at signup Move the browser-side device fingerprint out of user metadata and onto the Zitadel session created during signup. Device fingerprinting is intrinsically session-scoped: a single user can have many sessions across many devices, and keeping the token on the session avoids polluting a long-lived User resource with a transient signal. 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 - registerUser / registerUserAndLinkToIDP forward the token as Zitadel session metadata (key maxmind/tracking-token) via the new metadata parameter on createSessionAndUpdateCookie / createSessionForIdpAndUpdateCookie / createSessionFromChecks / createSessionForUserIdAndIdpIntent. addHumanUser is unchanged - Document NEXT_PUBLIC_MAXMIND_ACCOUNT_ID in next-env-vars.d.ts auth-provider-zitadel's session apiserver surfaces the metadata entry on the milo Session annotation iam.miloapis.com/maxmind-tracking-token which the fraud service reads when constructing the minFraud request. 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. --- apps/login/next-env-vars.d.ts | 7 ++ apps/login/src/app/(main)/layout.tsx | 7 ++ .../components/maxmind/maxmind-tracker.tsx | 98 +++++++++++++++++++ .../register-form-idp-incomplete.tsx | 2 + apps/login/src/components/register-form.tsx | 2 + .../components/set-register-password-form.tsx | 2 + apps/login/src/lib/server/cookie.ts | 13 +++ apps/login/src/lib/server/register.ts | 25 +++++ apps/login/src/lib/zitadel.ts | 32 +++++- 9 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 apps/login/src/components/maxmind/maxmind-tracker.tsx diff --git a/apps/login/next-env-vars.d.ts b/apps/login/next-env-vars.d.ts index 3cbc79bb71..2dbcd1aad1 100644 --- a/apps/login/next-env-vars.d.ts +++ b/apps/login/next-env-vars.d.ts @@ -44,5 +44,12 @@ declare namespace NodeJS { * Optional: the Fathom analytics id */ FATHOM_ID?: string; + + /** + * Optional: the MaxMind minFraud account id used to load the + * device-tracking JavaScript snippet at signup. When unset, the + * tracker component is not rendered and no token is collected. + */ + NEXT_PUBLIC_MAXMIND_ACCOUNT_ID?: string; } } diff --git a/apps/login/src/app/(main)/layout.tsx b/apps/login/src/app/(main)/layout.tsx index 0ef78080ec..2af2e46350 100644 --- a/apps/login/src/app/(main)/layout.tsx +++ b/apps/login/src/app/(main)/layout.tsx @@ -7,6 +7,7 @@ import { FathomAnalytics } from "@/components/fathom/fathom"; import { LanguageProvider } from "@/components/language-provider"; import { Loader } from "@/components/loader"; import MarkerIoEmbed from "@/components/markerio/markerio"; +import { MaxMindTracker } from "@/components/maxmind/maxmind-tracker"; import { ThemeProvider } from "@/components/theme-provider"; import { SITE_CONFIG } from "@/config/site"; import { alliance, canelaText, frontliner } from "@/lib/fonts/fonts"; @@ -64,6 +65,12 @@ export default async function RootLayout({ + {process.env.NEXT_PUBLIC_MAXMIND_ACCOUNT_ID && ( + + )} + {process.env.MARKER_IO_PROJECT_ID && ( )} diff --git a/apps/login/src/components/maxmind/maxmind-tracker.tsx b/apps/login/src/components/maxmind/maxmind-tracker.tsx new file mode 100644 index 0000000000..f1c526e413 --- /dev/null +++ b/apps/login/src/components/maxmind/maxmind-tracker.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useEffect } from "react"; + +/** + * sessionStorage key under which we stash the MaxMind device-tracking token + * captured by device.js. The register form reads it just before submitting so + * the value can be forwarded to Zitadel as session metadata. + */ +export const MAXMIND_TOKEN_STORAGE_KEY = "datum.maxmind.trackingToken"; + +/** + * Cookie set by MaxMind's device.js after a successful fingerprint exchange. + * The cookie value is the tracking token; we mirror it into sessionStorage so + * we don't have to parse document.cookie at submit time. + */ +const MAXMIND_COOKIE_NAME = "__mmapiwsid"; + +function readMaxMindCookie(): string { + if (typeof document === "undefined") return ""; + const prefix = `${MAXMIND_COOKIE_NAME}=`; + for (const part of document.cookie.split(";")) { + const trimmed = part.trim(); + if (trimmed.startsWith(prefix)) { + return decodeURIComponent(trimmed.slice(prefix.length)); + } + } + return ""; +} + +/** + * MaxMindTracker loads the MaxMind minFraud device.js snippet on mount and + * polls for the resulting __mmapiwsid cookie, mirroring it into + * sessionStorage. Rendering is gated by NEXT_PUBLIC_MAXMIND_ACCOUNT_ID — if + * unset the component is a no-op, so dev and preview deployments never + * contact MaxMind. + */ +export function MaxMindTracker({ accountId }: { accountId: string }) { + useEffect(() => { + if (!accountId) return; + if (typeof window === "undefined") return; + + const w = window as unknown as { + __mmapiws?: { accountId?: string }; + }; + w.__mmapiws = w.__mmapiws || {}; + w.__mmapiws.accountId = accountId; + + // Load device.js (idempotent — guard against StrictMode double-mount). + if (!document.querySelector('script[data-maxmind="device"]')) { + const script = document.createElement("script"); + script.src = "https://device.maxmind.com/js/device.js"; + script.async = true; + script.dataset.maxmind = "device"; + document.body.appendChild(script); + } + + // Poll briefly for the tracking-token cookie. device.js sets it after + // an async fingerprint exchange, typically within a few hundred ms. + let attempts = 0; + const maxAttempts = 30; // ~6 seconds at 200ms cadence + const handle = window.setInterval(() => { + attempts++; + const token = readMaxMindCookie(); + if (token) { + try { + window.sessionStorage.setItem(MAXMIND_TOKEN_STORAGE_KEY, token); + } catch { + // sessionStorage may be disabled (private mode). Best-effort only. + } + window.clearInterval(handle); + return; + } + if (attempts >= maxAttempts) { + window.clearInterval(handle); + } + }, 200); + + return () => window.clearInterval(handle); + }, [accountId]); + + return null; +} + +/** + * Returns the MaxMind device-tracking token captured during the session, or + * undefined when none is available (sessionStorage blocked, MaxMind script + * not yet finished, or NEXT_PUBLIC_MAXMIND_ACCOUNT_ID unset). + */ +export function readMaxMindTrackingToken(): string | undefined { + if (typeof window === "undefined") return undefined; + try { + const value = window.sessionStorage.getItem(MAXMIND_TOKEN_STORAGE_KEY); + return value || undefined; + } catch { + return undefined; + } +} diff --git a/apps/login/src/components/register-form-idp-incomplete.tsx b/apps/login/src/components/register-form-idp-incomplete.tsx index b8a7765c9c..fe7f826d48 100644 --- a/apps/login/src/components/register-form-idp-incomplete.tsx +++ b/apps/login/src/components/register-form-idp-incomplete.tsx @@ -8,6 +8,7 @@ import { Alert } from "./alert"; import { BackButton } from "./back-button"; import { Button, ButtonVariants } from "./button"; import { TextInput } from "./input"; +import { readMaxMindTrackingToken } from "./maxmind/maxmind-tracker"; import { Spinner } from "./spinner"; import { Translated } from "./translated"; @@ -71,6 +72,7 @@ export function RegisterFormIDPIncomplete({ organization: organization, requestId: requestId, idpIntent: idpIntent, + deviceTrackingToken: readMaxMindTrackingToken(), }) .catch(() => { setError("Could not register user"); diff --git a/apps/login/src/components/register-form.tsx b/apps/login/src/components/register-form.tsx index 2ddcfb94fa..60d4b06b92 100644 --- a/apps/login/src/components/register-form.tsx +++ b/apps/login/src/components/register-form.tsx @@ -16,6 +16,7 @@ import { } from "./authentication-method-radio"; import { FormActions } from "./form-actions"; import { TextInput } from "./input"; +import { readMaxMindTrackingToken } from "./maxmind/maxmind-tracker"; import { Translated } from "./translated"; type Inputs = @@ -68,6 +69,7 @@ export function RegisterForm({ lastName: values.lastname, organization: organization, requestId: requestId, + deviceTrackingToken: readMaxMindTrackingToken(), }) .catch(() => { setError("Could not register user"); diff --git a/apps/login/src/components/set-register-password-form.tsx b/apps/login/src/components/set-register-password-form.tsx index 39e8708c1a..f31fd837b9 100644 --- a/apps/login/src/components/set-register-password-form.tsx +++ b/apps/login/src/components/set-register-password-form.tsx @@ -14,6 +14,7 @@ import { FieldValues, useForm } from "react-hook-form"; import { Alert } from "./alert"; import { FormActions } from "./form-actions"; import { TextInput } from "./input"; +import { readMaxMindTrackingToken } from "./maxmind/maxmind-tracker"; import { PasswordComplexity } from "./password-complexity"; import { Translated } from "./translated"; @@ -64,6 +65,7 @@ export function SetRegisterPasswordForm({ organization: organization, requestId: requestId, password: values.password, + deviceTrackingToken: readMaxMindTrackingToken(), }) .catch(() => { setError("Could not register user"); diff --git a/apps/login/src/lib/server/cookie.ts b/apps/login/src/lib/server/cookie.ts index 841fc06b3a..5cd1bea5a6 100644 --- a/apps/login/src/lib/server/cookie.ts +++ b/apps/login/src/lib/server/cookie.ts @@ -51,6 +51,14 @@ export async function createSessionAndUpdateCookie(command: { checks: Checks; requestId: string | undefined; lifetime?: Duration; + /** + * Arbitrary key/value pairs to attach to the Zitadel session as + * metadata. Today this is how the MaxMind device-tracking token + * captured at signup reaches the fraud service: it rides on the + * session and is surfaced as an annotation on the milo Session by + * the auth-provider-zitadel apiserver. + */ + metadata?: Record; }): Promise { const _headers = await headers(); const { serviceUrl } = getServiceUrlFromHeaders(_headers); @@ -59,6 +67,7 @@ export async function createSessionAndUpdateCookie(command: { serviceUrl, checks: command.checks, lifetime: command.lifetime, + metadata: command.metadata, }); if (createdSession) { @@ -114,6 +123,7 @@ export async function createSessionForIdpAndUpdateCookie({ idpIntent, requestId, lifetime, + metadata, }: { userId: string; idpIntent: { @@ -122,6 +132,8 @@ export async function createSessionForIdpAndUpdateCookie({ }; requestId: string | undefined; lifetime?: Duration; + /** See createSessionAndUpdateCookie. */ + metadata?: Record; }): Promise { const _headers = await headers(); const { serviceUrl } = getServiceUrlFromHeaders(_headers); @@ -131,6 +143,7 @@ export async function createSessionForIdpAndUpdateCookie({ userId, idpIntent, lifetime, + metadata, }).catch((error: ErrorDetail | CredentialsCheckError) => { console.error("Could not set session", error); if ("failedAttempts" in error && error.failedAttempts) { diff --git a/apps/login/src/lib/server/register.ts b/apps/login/src/lib/server/register.ts index 1824a54ecb..4d1d20d22d 100644 --- a/apps/login/src/lib/server/register.ts +++ b/apps/login/src/lib/server/register.ts @@ -22,6 +22,16 @@ import { getNextUrl } from "../client"; import { getServiceUrlFromHeaders } from "../service-url"; import { checkEmailVerification } from "../verify-helper"; +/** + * Zitadel session-metadata key carrying the MaxMind minFraud device-tracking + * token captured by the browser at signup. auth-provider-zitadel's session + * apiserver mirrors this entry onto the milo Session annotation + * iam.miloapis.com/maxmind-tracking-token, which the fraud service then + * forwards to MaxMind as device.tracking_token. Keep this constant in sync + * with the metadataAnnotationKeys allowlist in the Go side. + */ +const MAXMIND_TRACKING_TOKEN_METADATA_KEY = "maxmind/tracking-token"; + type RegisterUserCommand = { email: string; firstName: string; @@ -29,8 +39,19 @@ type RegisterUserCommand = { password?: string; organization: string; requestId?: string; + /** + * MaxMind device-tracking token from the browser, when available. Attached + * to the Zitadel session created during signup; silently dropped if empty. + */ + deviceTrackingToken?: string; }; +function sessionMetadataFor( + token?: string, +): Record | undefined { + return token ? { [MAXMIND_TRACKING_TOKEN_METADATA_KEY]: token } : undefined; +} + export type RegisterUserResponse = { userId: string; sessionId: string; @@ -82,6 +103,7 @@ export async function registerUser(command: RegisterUserCommand) { lifetime: command.password ? loginSettings?.passwordCheckLifetime : undefined, + metadata: sessionMetadataFor(command.deviceTrackingToken), }); if (!session || !session.factors?.user) { @@ -156,6 +178,8 @@ type RegisterUserAndLinkToIDPommand = { idpUserId: string; idpId: string; idpUserName: string; + /** See RegisterUserCommand.deviceTrackingToken. */ + deviceTrackingToken?: string; }; export type registerUserAndLinkToIDPResponse = { @@ -210,6 +234,7 @@ export async function registerUserAndLinkToIDP( userId: addResponse.userId, // the user we just created idpIntent: command.idpIntent, lifetime: loginSettings?.externalLoginCheckLifetime, + metadata: sessionMetadataFor(command.deviceTrackingToken), }); if (!session || !session.factors?.user) { diff --git a/apps/login/src/lib/zitadel.ts b/apps/login/src/lib/zitadel.ts index 45a0409c2a..82bc6e7de7 100644 --- a/apps/login/src/lib/zitadel.ts +++ b/apps/login/src/lib/zitadel.ts @@ -311,21 +311,48 @@ export async function getPasswordComplexitySettings({ return useCache ? cacheWrapper(callback) : callback; } +/** + * Builds the Zitadel session-metadata payload from a plain string map. + * Zitadel stores metadata values as bytes; we encode each value with + * TextEncoder so the consumer (auth-provider-zitadel) can string-decode + * it back without ambiguity. Returns undefined when the input is empty + * so we don't push an empty map onto the gRPC request. + */ +function encodeSessionMetadata( + metadata?: Record, +): Record | undefined { + if (!metadata) return undefined; + const entries = Object.entries(metadata).filter(([, v]) => !!v); + if (entries.length === 0) return undefined; + const encoder = new TextEncoder(); + return Object.fromEntries(entries.map(([k, v]) => [k, encoder.encode(v)])); +} + export async function createSessionFromChecks({ serviceUrl, checks, lifetime, + metadata, }: { serviceUrl: string; checks: Checks; lifetime?: Duration; + metadata?: Record; }) { const sessionService: Client = await createServiceForHost(SessionService, serviceUrl); const userAgent = await getUserAgent(); - return sessionService.createSession({ checks, lifetime, userAgent }, {}); + return sessionService.createSession( + { + checks, + lifetime, + userAgent, + metadata: encodeSessionMetadata(metadata), + }, + {}, + ); } export async function createSessionForUserIdAndIdpIntent({ @@ -333,6 +360,7 @@ export async function createSessionForUserIdAndIdpIntent({ userId, idpIntent, lifetime, + metadata, }: { serviceUrl: string; userId: string; @@ -341,6 +369,7 @@ export async function createSessionForUserIdAndIdpIntent({ idpIntentToken?: string | undefined; }; lifetime?: Duration; + metadata?: Record; }) { const sessionService: Client = await createServiceForHost(SessionService, serviceUrl); @@ -359,6 +388,7 @@ export async function createSessionForUserIdAndIdpIntent({ }, lifetime, userAgent, + metadata: encodeSessionMetadata(metadata), }); }