Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/login/next-env-vars.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
7 changes: 7 additions & 0 deletions apps/login/src/app/(main)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -64,6 +65,12 @@ export default async function RootLayout({
</ThemeProvider>
<FathomAnalytics />

{process.env.NEXT_PUBLIC_MAXMIND_ACCOUNT_ID && (
<MaxMindTracker
accountId={process.env.NEXT_PUBLIC_MAXMIND_ACCOUNT_ID}
/>
)}

{process.env.MARKER_IO_PROJECT_ID && (
<MarkerIoEmbed projectId={process.env.MARKER_IO_PROJECT_ID} />
)}
Expand Down
98 changes: 98 additions & 0 deletions apps/login/src/components/maxmind/maxmind-tracker.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
}
2 changes: 2 additions & 0 deletions apps/login/src/components/register-form-idp-incomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -71,6 +72,7 @@ export function RegisterFormIDPIncomplete({
organization: organization,
requestId: requestId,
idpIntent: idpIntent,
deviceTrackingToken: readMaxMindTrackingToken(),
})
.catch(() => {
setError("Could not register user");
Expand Down
2 changes: 2 additions & 0 deletions apps/login/src/components/register-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -68,6 +69,7 @@ export function RegisterForm({
lastName: values.lastname,
organization: organization,
requestId: requestId,
deviceTrackingToken: readMaxMindTrackingToken(),
})
.catch(() => {
setError("Could not register user");
Expand Down
2 changes: 2 additions & 0 deletions apps/login/src/components/set-register-password-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -64,6 +65,7 @@ export function SetRegisterPasswordForm({
organization: organization,
requestId: requestId,
password: values.password,
deviceTrackingToken: readMaxMindTrackingToken(),
})
.catch(() => {
setError("Could not register user");
Expand Down
13 changes: 13 additions & 0 deletions apps/login/src/lib/server/cookie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
}): Promise<Session> {
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
Expand All @@ -59,6 +67,7 @@ export async function createSessionAndUpdateCookie(command: {
serviceUrl,
checks: command.checks,
lifetime: command.lifetime,
metadata: command.metadata,
});

if (createdSession) {
Expand Down Expand Up @@ -114,6 +123,7 @@ export async function createSessionForIdpAndUpdateCookie({
idpIntent,
requestId,
lifetime,
metadata,
}: {
userId: string;
idpIntent: {
Expand All @@ -122,6 +132,8 @@ export async function createSessionForIdpAndUpdateCookie({
};
requestId: string | undefined;
lifetime?: Duration;
/** See createSessionAndUpdateCookie. */
metadata?: Record<string, string>;
}): Promise<Session> {
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
Expand All @@ -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) {
Expand Down
25 changes: 25 additions & 0 deletions apps/login/src/lib/server/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,36 @@ 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;
lastName: string;
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<string, string> | undefined {
return token ? { [MAXMIND_TRACKING_TOKEN_METADATA_KEY]: token } : undefined;
}

export type RegisterUserResponse = {
userId: string;
sessionId: string;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -156,6 +178,8 @@ type RegisterUserAndLinkToIDPommand = {
idpUserId: string;
idpId: string;
idpUserName: string;
/** See RegisterUserCommand.deviceTrackingToken. */
deviceTrackingToken?: string;
};

export type registerUserAndLinkToIDPResponse = {
Expand Down Expand Up @@ -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) {
Expand Down
32 changes: 31 additions & 1 deletion apps/login/src/lib/zitadel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,28 +311,56 @@ 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<string, string>,
): Record<string, Uint8Array> | 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<string, string>;
}) {
const sessionService: Client<typeof SessionService> =
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({
serviceUrl,
userId,
idpIntent,
lifetime,
metadata,
}: {
serviceUrl: string;
userId: string;
Expand All @@ -341,6 +369,7 @@ export async function createSessionForUserIdAndIdpIntent({
idpIntentToken?: string | undefined;
};
lifetime?: Duration;
metadata?: Record<string, string>;
}) {
const sessionService: Client<typeof SessionService> =
await createServiceForHost(SessionService, serviceUrl);
Expand All @@ -359,6 +388,7 @@ export async function createSessionForUserIdAndIdpIntent({
},
lifetime,
userAgent,
metadata: encodeSessionMetadata(metadata),
});
}

Expand Down
Loading