From c53650c70a8dc8922102c5344d8f0f503d89bdd9 Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Mon, 17 Aug 2026 18:53:44 +0530 Subject: [PATCH] feat: send Auth0-CLI-Metadata header on Management API requests Adds a base64-encoded JSON payload identifying the invoker (agent, ci or human) along with the agent name, so agent, CI and human traffic can be told apart in telemetry. The header is stamped by a transport wrapping the retry chain, so it is applied once and survives retries on both the v1 and v3 management clients. --- internal/cli/agent_detection.go | 8 +- internal/cli/cli.go | 6 +- internal/cli/invoker_metadata.go | 103 ++++++++ internal/cli/invoker_metadata_test.go | 347 ++++++++++++++++++++++++++ internal/cli/management.go | 23 +- internal/cli/management_test.go | 6 +- 6 files changed, 477 insertions(+), 16 deletions(-) create mode 100644 internal/cli/invoker_metadata.go create mode 100644 internal/cli/invoker_metadata_test.go diff --git a/internal/cli/agent_detection.go b/internal/cli/agent_detection.go index 5d4eb9c99..4f6f9aa61 100644 --- a/internal/cli/agent_detection.go +++ b/internal/cli/agent_detection.go @@ -9,6 +9,10 @@ import ( "sync" ) +// agentClientUnknownAgent is returned when an agent was detected but could not be named. +// It is distinct from "unknown", which means no invoker signal was found at all. +const agentClientUnknownAgent = "unknown-agent" + // agentEnvEntry maps an env var to a canonical agent_client name. // The requiredPrefix field restricts matching to values with that prefix (case-insensitive). type agentEnvEntry struct { @@ -36,7 +40,7 @@ var agentEnvTable = []agentEnvEntry{ {envVar: "ANTIGRAVITY_CLI_ALIAS", agentName: "antigravity"}, {envVar: "ANTIGRAVITY_CONVERSATION_ID", agentName: "antigravity"}, // AI_AGENT catch-all (must be last). - {envVar: "AI_AGENT", agentName: "unknown-agent"}, + {envVar: "AI_AGENT", agentName: agentClientUnknownAgent}, } // agentProcessNames maps parent process names (partial, lower-cased) to agent names. @@ -114,7 +118,7 @@ func detectAgentWithEnv( } for _, suffix := range agentEnvSuffixes { if strings.HasSuffix(upperKey, suffix) { - return "unknown-agent" + return agentClientUnknownAgent } } } diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 0648483a6..6eb5673f0 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -136,12 +136,14 @@ func (c *cli) setupWithAuthentication(ctx context.Context) error { ) } - api, err := initializeManagementClient(tenant.Domain, tenant.GetAccessToken()) + invokerMetadata := c.invokerMetadataHeaderValue() + + api, err := initializeManagementClient(tenant.Domain, tenant.GetAccessToken(), invokerMetadata) if err != nil { return err } - apiv3, err := initializeManagementClientV3(tenant.Domain, tenant.GetAccessToken()) + apiv3, err := initializeManagementClientV3(tenant.Domain, tenant.GetAccessToken(), invokerMetadata) if err != nil { return err } diff --git a/internal/cli/invoker_metadata.go b/internal/cli/invoker_metadata.go new file mode 100644 index 000000000..9dc243a1e --- /dev/null +++ b/internal/cli/invoker_metadata.go @@ -0,0 +1,103 @@ +package cli + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "os" +) + +// invokerMetadataHeader carries structured information about who invoked the CLI so +// that human, AI agent and CI traffic can be told apart. It is a dedicated header +// rather than extra User-Agent tokens so new fields can be added without reparsing. +// +// Spelled in Go's canonical form: net/http canonicalizes header names, so "CLI" would +// go out as "Cli" regardless. Header names are case-insensitive per RFC 9110 section 5.1, +// and HTTP/2 lower-cases them, so the server must match this case-insensitively. +const invokerMetadataHeader = "Auth0-Cli-Metadata" + +const ( + invokerKindAgent = "agent" + invokerKindCI = "ci" + invokerKindHuman = "human" + // InvokerUnknown is the invoker_agent value when no agent can be named. It is also + // what detectAgent returns for a non-interactive invocation carrying no agent signal. + invokerUnknown = "unknown" +) + +// invokerMetadata is the JSON payload of the Auth0-Cli-Metadata header. +// +// InvokerKind is the single best label for the invoker, always one of "agent", "ci" or +// "human", and CI is the raw environment signal. They are deliberately not merged: an +// agent running inside CI reports kind "agent" so the agent is not lost, and CI true so +// the environment is not either. Never use omitempty here, since an explicit false +// ("not CI") carries different meaning from an absent field ("this CLI version did not +// report it"). +type invokerMetadata struct { + InvokerKind string `json:"invoker_kind"` + InvokerAgent string `json:"invoker_agent"` + CI bool `json:"ci"` +} + +// resolveInvokerMetadata maps a detected agent client (see detectAgent) onto the header +// payload. A named agent takes precedence over CI for InvokerKind, since an agent +// running inside CI is still an agent; the CI field preserves that combination. +func resolveInvokerMetadata(agentClient string, isCI bool) invokerMetadata { + kind, agent := invokerKindAgent, agentClient + + switch agentClient { + // No agent signal at all, so this is a person. Both fallbacks land here: detectAgent + // says "human" on a TTY and "unknown" without one, but a human piping output or + // driving the CLI from a script has no TTY and is still human. CI overrides, because + // a CI run is a more specific signal than the absence of a terminal. + case invokerKindHuman, invokerUnknown: + kind, agent = invokerKindHuman, invokerUnknown + if isCI { + kind = invokerKindCI + } + // An agent was detected but could not be named. + case agentClientUnknownAgent: + agent = invokerUnknown + } + + return invokerMetadata{InvokerKind: kind, InvokerAgent: agent, CI: isCI} +} + +// headerValue renders the metadata as base64-encoded JSON. The encoding mirrors the +// sibling "Auth0-Client" header (base64.StdEncoding over json.Marshal) so the server +// side can reuse the same decode path, and it keeps the JSON commas out of a header +// value, which intermediaries are otherwise allowed to split on per RFC 9110. +// Returns an empty string if the payload cannot be encoded, in which case the header +// is omitted rather than sent blank. +func (m invokerMetadata) headerValue() string { + value, err := json.Marshal(m) + if err != nil { + return "" + } + + return base64.StdEncoding.EncodeToString(value) +} + +// invokerMetadataHeaderValue returns the Auth0-CLI-Metadata value for this invocation, +// reusing the cached agent detection that mode resolution and analytics also rely on. +func (c *cli) invokerMetadataHeaderValue() string { + return resolveInvokerMetadata(c.agentClientName(), isCIEnvironment(os.Getenv)).headerValue() +} + +// invokerMetadataTransport sets the Auth0-CLI-Metadata header on every outbound request. +type invokerMetadataTransport struct { + base http.RoundTripper + metadata string +} + +func (t invokerMetadataTransport) RoundTrip(request *http.Request) (*http.Response, error) { + if t.metadata == "" { + return t.base.RoundTrip(request) + } + + // Clone before mutating: a RoundTripper must not modify the request it is given. + request = request.Clone(request.Context()) + request.Header.Set(invokerMetadataHeader, t.metadata) + + return t.base.RoundTrip(request) +} diff --git a/internal/cli/invoker_metadata_test.go b/internal/cli/invoker_metadata_test.go new file mode 100644 index 000000000..faf99c4f5 --- /dev/null +++ b/internal/cli/invoker_metadata_test.go @@ -0,0 +1,347 @@ +package cli + +import ( + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/auth0/go-auth0/v3/management" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveInvokerMetadata(t *testing.T) { + testCases := []struct { + name string + agentClient string + isCI bool + expectedKind string + expectedName string + }{ + { + name: "named agent", + agentClient: "claude-code", + expectedKind: "agent", + expectedName: "claude-code", + }, + { + // The CI field is what keeps the environment from being lost here. + name: "named agent inside CI still reports as agent", + agentClient: "cursor", + isCI: true, + expectedKind: "agent", + expectedName: "cursor", + }, + { + name: "unidentified agent", + agentClient: "unknown-agent", + expectedKind: "agent", + expectedName: "unknown", + }, + { + name: "unidentified agent inside CI", + agentClient: "unknown-agent", + isCI: true, + expectedKind: "agent", + expectedName: "unknown", + }, + { + name: "interactive human", + agentClient: "human", + expectedKind: "human", + expectedName: "unknown", + }, + { + // No TTY does not mean no person: piping output or running from a script + // still reports as human. + name: "non-interactive human outside CI", + agentClient: "unknown", + expectedKind: "human", + expectedName: "unknown", + }, + { + name: "human on a TTY inside CI", + agentClient: "human", + isCI: true, + expectedKind: "ci", + expectedName: "unknown", + }, + { + name: "no signal inside CI", + agentClient: "unknown", + isCI: true, + expectedKind: "ci", + expectedName: "unknown", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + metadata := resolveInvokerMetadata(testCase.agentClient, testCase.isCI) + + assert.Equal(t, testCase.expectedKind, metadata.InvokerKind) + assert.Equal(t, testCase.expectedName, metadata.InvokerAgent) + assert.Equal(t, testCase.isCI, metadata.CI, "the CI signal must be reported independently of the kind") + }) + } +} + +// TestInvokerKindIsAlwaysKnown pins invoker_kind to a closed set, so consumers can +// treat it as an enum. No detectAgent output resolves to "unknown": a missing agent +// signal means a person, whether or not a terminal is attached. +func TestInvokerKindIsAlwaysKnown(t *testing.T) { + // Every shape detectAgent can return: named agents, an unnamed agent, a sanitized + // AUTH0_CLI_CLIENT value, and both Tier 4 fallbacks. + agentClients := append( + []string{agentClientUnknownAgent, "client-something", invokerKindHuman, invokerUnknown}, + knownAgentClients..., + ) + + for _, agentClient := range agentClients { + for _, isCI := range []bool{false, true} { + metadata := resolveInvokerMetadata(agentClient, isCI) + + assert.Contains( + t, + []string{invokerKindAgent, invokerKindCI, invokerKindHuman}, + metadata.InvokerKind, + "agentClient %q with CI %v produced an out-of-set kind", agentClient, isCI, + ) + } + } +} + +// TestInvokerMetadataAlwaysReportsCI pins the absence of omitempty on the CI field: +// "not CI" must be an explicit false, not a missing key. +func TestInvokerMetadataAlwaysReportsCI(t *testing.T) { + value := resolveInvokerMetadata("claude-code", false).headerValue() + + decoded, err := base64.StdEncoding.DecodeString(value) + require.NoError(t, err) + assert.JSONEq( + t, + `{"invoker_kind":"agent","invoker_agent":"claude-code","ci":false}`, + string(decoded), + ) +} + +func TestInvokerMetadataHeaderValue(t *testing.T) { + value := invokerMetadata{InvokerKind: "agent", InvokerAgent: "claude-code"}.headerValue() + + // The value must be base64-encoded JSON, matching the sibling Auth0-Client header. + decoded, err := base64.StdEncoding.DecodeString(value) + require.NoError(t, err) + assert.JSONEq(t, `{"invoker_kind":"agent","invoker_agent":"claude-code","ci":false}`, string(decoded)) +} + +// TestInvokerMetadataHeaderIsCanonical guards the constant against Go's header +// canonicalization, so the name in code always matches the name on the wire. +func TestInvokerMetadataHeaderIsCanonical(t *testing.T) { + assert.Equal(t, invokerMetadataHeader, http.CanonicalHeaderKey(invokerMetadataHeader)) +} + +func TestInvokerMetadataTransportSetsHeader(t *testing.T) { + var receivedHeader string + + testServer := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, request *http.Request) { + receivedHeader = request.Header.Get(invokerMetadataHeader) + })) + t.Cleanup(testServer.Close) + + metadata := invokerMetadata{InvokerKind: "agent", InvokerAgent: "claude-code"}.headerValue() + client := customClientWithRetries(metadata) + + request, err := http.NewRequest(http.MethodGet, testServer.URL, nil) + require.NoError(t, err) + + response, err := client.Do(request) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, response.Body.Close()) + }) + + assert.Equal(t, metadata, receivedHeader) + assert.Empty(t, request.Header.Get(invokerMetadataHeader), "the original request should not be mutated") +} + +func TestInvokerMetadataTransportOmitsEmptyHeader(t *testing.T) { + headerPresent := true + + testServer := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, request *http.Request) { + _, headerPresent = request.Header[invokerMetadataHeader] + })) + t.Cleanup(testServer.Close) + + request, err := http.NewRequest(http.MethodGet, testServer.URL, nil) + require.NoError(t, err) + + response, err := customClientWithRetries("").Do(request) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, response.Body.Close()) + }) + + assert.False(t, headerPresent, "an empty payload should omit the header entirely") +} + +// trustTestServer points the shared http.DefaultTransport, which +// customClientWithRetries builds on, at the test server's certificate so a real TLS +// handshake succeeds without weakening verification. Restored on cleanup. +func trustTestServer(t *testing.T, server *httptest.Server) { + t.Helper() + + transport, ok := http.DefaultTransport.(*http.Transport) + require.True(t, ok, "http.DefaultTransport is expected to be *http.Transport") + + original := transport.TLSClientConfig + t.Cleanup(func() { transport.TLSClientConfig = original }) + + pool := x509.NewCertPool() + pool.AddCert(server.Certificate()) + transport.TLSClientConfig = &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12} +} + +// capturedRequest records what a request actually looked like on the server side. +type capturedRequest struct { + headerKeys []string + metadata string + auth0Cli string + userAgent string + proto string +} + +// newMetadataEchoServer serves an empty JSON list over TLS and captures the headers +// of the first request it receives. +func newMetadataEchoServer(t *testing.T, captured *capturedRequest) *httptest.Server { + t.Helper() + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if captured.proto == "" { + for key := range r.Header { + captured.headerKeys = append(captured.headerKeys, key) + } + captured.proto = r.Proto + captured.metadata = r.Header.Get(invokerMetadataHeader) + captured.auth0Cli = r.Header.Get("Auth0-Client") + captured.userAgent = r.Header.Get("User-Agent") + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + t.Cleanup(server.Close) + + return server +} + +// domainOf strips the scheme so the value matches what the CLI stores as a tenant domain. +func domainOf(server *httptest.Server) string { + return server.URL[len("https://"):] +} + +// assertMetadataHeader decodes the captured header and checks the payload. +func assertMetadataHeader(t *testing.T, captured capturedRequest, expected invokerMetadata) { + t.Helper() + + t.Logf("server received %s: %s", invokerMetadataHeader, captured.metadata) + + require.NotEmpty(t, captured.metadata, "the %s header never reached the server", invokerMetadataHeader) + + raw, err := base64.StdEncoding.DecodeString(captured.metadata) + require.NoError(t, err, "header value must be valid base64") + t.Logf("decoded payload: %s", raw) + + var decoded invokerMetadata + require.NoError(t, json.Unmarshal(raw, &decoded), "decoded header must be valid JSON") + assert.Equal(t, expected, decoded) + + // The header name must survive Go's canonicalization exactly as spelled. + assert.Contains(t, captured.headerKeys, invokerMetadataHeader) + + // Sanity check that this is a genuine SDK request and that our header rides + // alongside the existing telemetry rather than displacing it. + assert.Contains(t, captured.userAgent, userAgent) +} + +// TestManagementClientV1SendsInvokerMetadata drives the real v1 client constructor +// through a real TLS request and asserts the header arrives intact. +func TestManagementClientV1SendsInvokerMetadata(t *testing.T) { + var captured capturedRequest + server := newMetadataEchoServer(t, &captured) + trustTestServer(t, server) + + metadata := invokerMetadata{InvokerKind: "agent", InvokerAgent: "claude-code", CI: true} + + api, err := initializeManagementClient(domainOf(server), "test-token", metadata.headerValue()) + require.NoError(t, err) + + _, err = api.ResourceServer.List(t.Context()) + require.NoError(t, err) + + assertMetadataHeader(t, captured, metadata) + assert.NotEmpty(t, captured.auth0Cli, "Auth0-Client should still be sent alongside it") +} + +// TestManagementClientV3SendsInvokerMetadata does the same for the v3 client, since +// both share customClientWithRetries and both must carry the header. +func TestManagementClientV3SendsInvokerMetadata(t *testing.T) { + var captured capturedRequest + server := newMetadataEchoServer(t, &captured) + trustTestServer(t, server) + + metadata := invokerMetadata{InvokerKind: "human", InvokerAgent: "unknown", CI: false} + + api, err := initializeManagementClientV3(domainOf(server), "test-token", metadata.headerValue()) + require.NoError(t, err) + + _, err = api.ClientGrants.List(t.Context(), &management.ListClientGrantsRequestParameters{}) + require.NoError(t, err) + + assertMetadataHeader(t, captured, metadata) + + // Note: unlike v1, the v3 client does not send Auth0-Client here despite + // option.WithAuth0ClientEnvEntry being configured. Passing option.WithHTTPClient + // replaces the client the SDK built, discarding its Auth0-Client transport. That is + // pre-existing SDK behaviour, unrelated to this header, and recorded for visibility. + t.Logf("v3 Auth0-Client: %q", captured.auth0Cli) +} + +// TestManagementClientSendsMetadataOnRetries proves the header is present on retried +// attempts too, not just the first, since it is stamped outside the retry transports. +func TestManagementClientSendsMetadataOnRetries(t *testing.T) { + var attempts int + var metadataPerAttempt []string + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts++ + metadataPerAttempt = append(metadataPerAttempt, r.Header.Get(invokerMetadataHeader)) + + // Fail the first attempt with a retryable status. + if attempts == 1 { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + t.Cleanup(server.Close) + trustTestServer(t, server) + + metadata := invokerMetadata{InvokerKind: "agent", InvokerAgent: "cursor", CI: false} + + api, err := initializeManagementClient(domainOf(server), "test-token", metadata.headerValue()) + require.NoError(t, err) + + _, err = api.ResourceServer.List(t.Context()) + require.NoError(t, err) + + require.Equal(t, 2, attempts, "expected one retry after the 503") + for attempt, value := range metadataPerAttempt { + assert.Equal(t, metadata.headerValue(), value, "attempt %d lost the header", attempt+1) + } +} diff --git a/internal/cli/management.go b/internal/cli/management.go index 69e486877..8e73206b1 100644 --- a/internal/cli/management.go +++ b/internal/cli/management.go @@ -19,20 +19,20 @@ import ( "github.com/auth0/auth0-cli/internal/buildinfo" ) -func initializeManagementClient(tenantDomain string, accessToken string) (*management.Management, error) { +func initializeManagementClient(tenantDomain string, accessToken string, invokerMetadata string) (*management.Management, error) { client, err := management.New( tenantDomain, management.WithStaticToken(accessToken), management.WithUserAgent(fmt.Sprintf("%v/%v", userAgent, strings.TrimPrefix(buildinfo.Version, "v"))), management.WithAuth0ClientEnvEntry("Auth0-CLI", strings.TrimPrefix(buildinfo.Version, "v")), management.WithNoRetries(), - management.WithClient(customClientWithRetries()), + management.WithClient(customClientWithRetries(invokerMetadata)), ) return client, err } -func initializeManagementClientV3(tenantDomain string, accessToken string) (*managementv3.Management, error) { +func initializeManagementClientV3(tenantDomain string, accessToken string, invokerMetadata string) (*managementv3.Management, error) { client, err := managementv3.New( tenantDomain, option.WithToken(accessToken), @@ -42,18 +42,23 @@ func initializeManagementClientV3(tenantDomain string, accessToken string) (*man // Setting it to 1 to avoid retries from `go-auth0` since we have our own retry logic in the custom HTTP client. // TODO: confirm this assumption, or check if this needs to be excluded like terraform provider. option.WithMaxAttempts(1), - option.WithHTTPClient(customClientWithRetries()), + option.WithHTTPClient(customClientWithRetries(invokerMetadata)), ) return client, err } -func customClientWithRetries() *http.Client { +func customClientWithRetries(invokerMetadata string) *http.Client { client := &http.Client{ - Transport: rateLimitTransport( - retryableErrorTransport( - http.DefaultTransport, + // The metadata transport wraps the retry transports so the header is stamped + // once, before any retry decision, and is preserved across retries. + Transport: invokerMetadataTransport{ + metadata: invokerMetadata, + base: rateLimitTransport( + retryableErrorTransport( + http.DefaultTransport, + ), ), - ), + }, } return client diff --git a/internal/cli/management_test.go b/internal/cli/management_test.go index 2342eb18f..8ee989048 100644 --- a/internal/cli/management_test.go +++ b/internal/cli/management_test.go @@ -33,7 +33,7 @@ func TestCustomClientWithRetries(t *testing.T) { writer.WriteHeader(200) })) - client := customClientWithRetries() + client := customClientWithRetries("") request, err := http.NewRequest(http.MethodGet, testServer.URL, nil) require.NoError(t, err) @@ -67,7 +67,7 @@ func TestCustomClientWithRetries(t *testing.T) { writer.WriteHeader(200) })) - client := customClientWithRetries() + client := customClientWithRetries("") request, err := http.NewRequest(http.MethodGet, testServer.URL, nil) require.NoError(t, err) @@ -93,7 +93,7 @@ func TestCustomClientWithRetries(t *testing.T) { writer.WriteHeader(500) })) - client := customClientWithRetries() + client := customClientWithRetries("") request, err := http.NewRequest(http.MethodGet, testServer.URL, nil) require.NoError(t, err)