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.