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
17 changes: 14 additions & 3 deletions internal/datasource/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
7 changes: 6 additions & 1 deletion internal/provider/maxmind/maxmind.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
}
}

Expand Down
33 changes: 33 additions & 0 deletions internal/provider/maxmind/maxmind_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{})
Expand All @@ -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 {
Expand Down Expand Up @@ -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()

Expand Down
5 changes: 5 additions & 0 deletions internal/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading