From 3512015d8b82327c5e1473da2ffe663c6a811201 Mon Sep 17 00:00:00 2001 From: Matt Jenkinson <75292329+mattdjenkinson@users.noreply.github.com> Date: Mon, 11 May 2026 15:32:10 +0200 Subject: [PATCH] feat: forward MaxMind device tracking_token to minFraud requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plumb the browser-collected MaxMind trackingToken into the minFraud request body so the score reflects the device fingerprint captured at signup, not just IP and email. The token rides on the milo Session (annotation iam.miloapis.com/maxmind-tracking-token, populated by auth-provider-zitadel from Zitadel session metadata) — device fingerprinting is intrinsically session-scoped, not user-scoped. Key features/changes: - Add TrackingToken to provider.Input as an optional field - Resolver reads the iam.miloapis.com/maxmind-tracking-token Session annotation on the latest session for the user and populates input - MaxMind client adds device.tracking_token to the request body and builds the device block when the token is the only device-side signal present (e.g. session API was unavailable) - Test coverage: assert tracking_token is sent in the all-fields case and that a token-only input still produces a device block - Log resolved input with hasTrackingToken=bool to surface presence without leaking the token itself Missing annotation is non-fatal — fraud evaluation continues with the existing IP/email/UA signals from the User and Session. --- internal/datasource/resolver.go | 17 +++++++++--- internal/provider/maxmind/maxmind.go | 7 ++++- internal/provider/maxmind/maxmind_test.go | 33 +++++++++++++++++++++++ internal/provider/provider.go | 5 ++++ 4 files changed, 58 insertions(+), 4 deletions(-) diff --git a/internal/datasource/resolver.go b/internal/datasource/resolver.go index 4099d44..db8648b 100644 --- a/internal/datasource/resolver.go +++ b/internal/datasource/resolver.go @@ -16,6 +16,13 @@ import ( "go.miloapis.com/fraud/internal/provider" ) +// MaxMindTrackingTokenAnnotation is the milo Session annotation key that +// auth-provider-zitadel populates from Zitadel session metadata. The +// resolver reads it from the latest Session and forwards the value to +// MaxMind as device.tracking_token. Missing annotation is non-fatal — +// fraud evaluation continues with the other signals. +const MaxMindTrackingTokenAnnotation = "iam.miloapis.com/maxmind-tracking-token" + // Resolver fetches data from the platform's User CRD and the identity Sessions // API to build a provider.Input for the fraud evaluation pipeline. type Resolver struct { @@ -54,6 +61,7 @@ func (r *Resolver) Resolve(ctx context.Context, userUID string) (provider.Input, "emailDomain", input.EmailDomain, "ip", input.IPAddress, "userAgent", input.UserAgent, + "hasTrackingToken", input.TrackingToken != "", ) return input, sessionErr @@ -106,9 +114,12 @@ func (r *Resolver) resolveSession(ctx context.Context, userUID string, input *pr return ti.After(tj) }) - latest := sessions.Items[0].Status - input.IPAddress = latest.IP - input.UserAgent = latest.UserAgent + latest := sessions.Items[0] + input.IPAddress = latest.Status.IP + input.UserAgent = latest.Status.UserAgent + if token := latest.Annotations[MaxMindTrackingTokenAnnotation]; token != "" { + input.TrackingToken = token + } return nil } diff --git a/internal/provider/maxmind/maxmind.go b/internal/provider/maxmind/maxmind.go index 4be468d..8224c63 100644 --- a/internal/provider/maxmind/maxmind.go +++ b/internal/provider/maxmind/maxmind.go @@ -80,6 +80,10 @@ type deviceField struct { IPAddress string `json:"ip_address,omitempty"` UserAgent string `json:"user_agent,omitempty"` AcceptLanguage string `json:"accept_language,omitempty"` + // TrackingToken is the value returned by the device.js / trackDevice() + // browser SDK. MaxMind documents this as `device.tracking_token` and uses + // it for explicit device linking across sessions. + TrackingToken string `json:"tracking_token,omitempty"` } type emailField struct { @@ -158,11 +162,12 @@ func buildRequest(input provider.Input) minfraudRequest { var req minfraudRequest // Build device field if any device-related input is provided. - if input.IPAddress != "" || input.UserAgent != "" || input.AcceptLanguage != "" { + if input.IPAddress != "" || input.UserAgent != "" || input.AcceptLanguage != "" || input.TrackingToken != "" { req.Device = &deviceField{ IPAddress: input.IPAddress, UserAgent: input.UserAgent, AcceptLanguage: input.AcceptLanguage, + TrackingToken: input.TrackingToken, } } diff --git a/internal/provider/maxmind/maxmind_test.go b/internal/provider/maxmind/maxmind_test.go index 88c0302..4b86896 100644 --- a/internal/provider/maxmind/maxmind_test.go +++ b/internal/provider/maxmind/maxmind_test.go @@ -160,6 +160,7 @@ func TestEvaluate_AllFieldsSent(t *testing.T) { EmailDomain: "example.com", UserAgent: "Mozilla/5.0", AcceptLanguage: "en-US", + TrackingToken: "tok-abc123", }) device, ok := receivedBody["device"].(map[string]interface{}) @@ -171,6 +172,7 @@ func TestEvaluate_AllFieldsSent(t *testing.T) { "ip_address": "1.2.3.4", "user_agent": "Mozilla/5.0", "accept_language": "en-US", + "tracking_token": "tok-abc123", } for k, v := range want { if device[k] != v { @@ -218,6 +220,37 @@ func TestEvaluate_PartialInput(t *testing.T) { } } +func TestEvaluate_TrackingTokenOnly(t *testing.T) { + t.Parallel() + + var receivedBody map[string]interface{} + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &receivedBody) + + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{"risk_score": 5.0, "id": "test-id"}`) + })) + defer srv.Close() + + client := NewClient(srv.URL, "acct123", "key456") + client.Evaluate(context.Background(), provider.Input{TrackingToken: "tok-only"}) + + device, ok := receivedBody["device"].(map[string]interface{}) + if !ok { + t.Fatal("expected device field even when only tracking_token is set") + } + + if got := device["tracking_token"]; got != "tok-only" { + t.Errorf("device.tracking_token = %v, want %q", got, "tok-only") + } + + if _, ok := device["ip_address"]; ok { + t.Error("expected ip_address to be omitted when not set") + } +} + func TestEvaluate_EmptyInput(t *testing.T) { t.Parallel() diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 458adb6..9fe63cd 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -12,6 +12,11 @@ type Input struct { IPAddress string UserAgent string AcceptLanguage string + // TrackingToken is the MaxMind minFraud device-tracking token captured by + // the browser SDK and attached to the Zitadel session at signup. The + // resolver reads it from the Session annotation written by + // auth-provider-zitadel; providers forward it as device.tracking_token. + TrackingToken string } // Result holds the output of a provider evaluation.