Skip to content

Commit dc3a5c6

Browse files
Merge pull request #77 from datum-cloud/feat/maxmind-device-tracking
feat: capture MaxMind tracking token at signup
2 parents 7e4965b + 3168e91 commit dc3a5c6

9 files changed

Lines changed: 187 additions & 1 deletion

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

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ export async function createSessionAndUpdateCookie(command: {
5151
checks: Checks;
5252
requestId: string | undefined;
5353
lifetime?: Duration;
54+
/**
55+
* Arbitrary key/value pairs to attach to the Zitadel session as
56+
* metadata. Today this is how the MaxMind device-tracking token
57+
* captured at signup reaches the fraud service: it rides on the
58+
* session and is surfaced as an annotation on the milo Session by
59+
* the auth-provider-zitadel apiserver.
60+
*/
61+
metadata?: Record<string, string>;
5462
}): Promise<Session> {
5563
const _headers = await headers();
5664
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
@@ -59,6 +67,7 @@ export async function createSessionAndUpdateCookie(command: {
5967
serviceUrl,
6068
checks: command.checks,
6169
lifetime: command.lifetime,
70+
metadata: command.metadata,
6271
});
6372

6473
if (createdSession) {
@@ -114,6 +123,7 @@ export async function createSessionForIdpAndUpdateCookie({
114123
idpIntent,
115124
requestId,
116125
lifetime,
126+
metadata,
117127
}: {
118128
userId: string;
119129
idpIntent: {
@@ -122,6 +132,8 @@ export async function createSessionForIdpAndUpdateCookie({
122132
};
123133
requestId: string | undefined;
124134
lifetime?: Duration;
135+
/** See createSessionAndUpdateCookie. */
136+
metadata?: Record<string, string>;
125137
}): Promise<Session> {
126138
const _headers = await headers();
127139
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
@@ -131,6 +143,7 @@ export async function createSessionForIdpAndUpdateCookie({
131143
userId,
132144
idpIntent,
133145
lifetime,
146+
metadata,
134147
}).catch((error: ErrorDetail | CredentialsCheckError) => {
135148
console.error("Could not set session", error);
136149
if ("failedAttempts" in error && error.failedAttempts) {

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

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

25+
/**
26+
* Zitadel session-metadata key carrying the MaxMind minFraud device-tracking
27+
* token captured by the browser at signup. auth-provider-zitadel's session
28+
* apiserver mirrors this entry onto the milo Session annotation
29+
* iam.miloapis.com/maxmind-tracking-token, which the fraud service then
30+
* forwards to MaxMind as device.tracking_token. Keep this constant in sync
31+
* with the metadataAnnotationKeys allowlist in the Go side.
32+
*/
33+
const MAXMIND_TRACKING_TOKEN_METADATA_KEY = "maxmind/tracking-token";
34+
2535
type RegisterUserCommand = {
2636
email: string;
2737
firstName: string;
2838
lastName: string;
2939
password?: string;
3040
organization: string;
3141
requestId?: string;
42+
/**
43+
* MaxMind device-tracking token from the browser, when available. Attached
44+
* to the Zitadel session created during signup; silently dropped if empty.
45+
*/
46+
deviceTrackingToken?: string;
3247
};
3348

49+
function sessionMetadataFor(
50+
token?: string,
51+
): Record<string, string> | undefined {
52+
return token ? { [MAXMIND_TRACKING_TOKEN_METADATA_KEY]: token } : undefined;
53+
}
54+
3455
export type RegisterUserResponse = {
3556
userId: string;
3657
sessionId: string;
@@ -82,6 +103,7 @@ export async function registerUser(command: RegisterUserCommand) {
82103
lifetime: command.password
83104
? loginSettings?.passwordCheckLifetime
84105
: undefined,
106+
metadata: sessionMetadataFor(command.deviceTrackingToken),
85107
});
86108

87109
if (!session || !session.factors?.user) {
@@ -156,6 +178,8 @@ type RegisterUserAndLinkToIDPommand = {
156178
idpUserId: string;
157179
idpId: string;
158180
idpUserName: string;
181+
/** See RegisterUserCommand.deviceTrackingToken. */
182+
deviceTrackingToken?: string;
159183
};
160184

161185
export type registerUserAndLinkToIDPResponse = {
@@ -210,6 +234,7 @@ export async function registerUserAndLinkToIDP(
210234
userId: addResponse.userId, // the user we just created
211235
idpIntent: command.idpIntent,
212236
lifetime: loginSettings?.externalLoginCheckLifetime,
237+
metadata: sessionMetadataFor(command.deviceTrackingToken),
213238
});
214239

215240
if (!session || !session.factors?.user) {

apps/login/src/lib/zitadel.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,28 +311,56 @@ export async function getPasswordComplexitySettings({
311311
return useCache ? cacheWrapper(callback) : callback;
312312
}
313313

314+
/**
315+
* Builds the Zitadel session-metadata payload from a plain string map.
316+
* Zitadel stores metadata values as bytes; we encode each value with
317+
* TextEncoder so the consumer (auth-provider-zitadel) can string-decode
318+
* it back without ambiguity. Returns undefined when the input is empty
319+
* so we don't push an empty map onto the gRPC request.
320+
*/
321+
function encodeSessionMetadata(
322+
metadata?: Record<string, string>,
323+
): Record<string, Uint8Array> | undefined {
324+
if (!metadata) return undefined;
325+
const entries = Object.entries(metadata).filter(([, v]) => !!v);
326+
if (entries.length === 0) return undefined;
327+
const encoder = new TextEncoder();
328+
return Object.fromEntries(entries.map(([k, v]) => [k, encoder.encode(v)]));
329+
}
330+
314331
export async function createSessionFromChecks({
315332
serviceUrl,
316333
checks,
317334
lifetime,
335+
metadata,
318336
}: {
319337
serviceUrl: string;
320338
checks: Checks;
321339
lifetime?: Duration;
340+
metadata?: Record<string, string>;
322341
}) {
323342
const sessionService: Client<typeof SessionService> =
324343
await createServiceForHost(SessionService, serviceUrl);
325344

326345
const userAgent = await getUserAgent();
327346

328-
return sessionService.createSession({ checks, lifetime, userAgent }, {});
347+
return sessionService.createSession(
348+
{
349+
checks,
350+
lifetime,
351+
userAgent,
352+
metadata: encodeSessionMetadata(metadata),
353+
},
354+
{},
355+
);
329356
}
330357

331358
export async function createSessionForUserIdAndIdpIntent({
332359
serviceUrl,
333360
userId,
334361
idpIntent,
335362
lifetime,
363+
metadata,
336364
}: {
337365
serviceUrl: string;
338366
userId: string;
@@ -341,6 +369,7 @@ export async function createSessionForUserIdAndIdpIntent({
341369
idpIntentToken?: string | undefined;
342370
};
343371
lifetime?: Duration;
372+
metadata?: Record<string, string>;
344373
}) {
345374
const sessionService: Client<typeof SessionService> =
346375
await createServiceForHost(SessionService, serviceUrl);
@@ -359,6 +388,7 @@ export async function createSessionForUserIdAndIdpIntent({
359388
},
360389
lifetime,
361390
userAgent,
391+
metadata: encodeSessionMetadata(metadata),
362392
});
363393
}
364394

0 commit comments

Comments
 (0)