From 9a3087d5b37c26e42f3b4ac7c4e43808a57febc4 Mon Sep 17 00:00:00 2001 From: "Customer.io Open Source Bot" Date: Mon, 15 Jun 2026 19:48:57 -0400 Subject: [PATCH] Add Customer.io CLI source CioCliPublicExport-RevId: 26b6fb95d5b91270ad3f8b7362c4ac85b0b970e8 --- cmd/auth.go | 72 +++++++++++++++++++------ cmd/auth_test.go | 137 ++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 187 insertions(+), 22 deletions(-) diff --git a/cmd/auth.go b/cmd/auth.go index 2b062bc..884b52b 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -43,7 +43,7 @@ var ( // contents are ignored; they are never echoed, stored, or sent anywhere. func clipboardToken(cmd *cobra.Command, wait bool) (string, error) { if wait { - fmt.Fprintf(cmd.ErrOrStderr(), "Sign in and copy your token from:\n\n %s\n\nWaiting for it on your clipboard (Ctrl-C cancels)...\n", resolveCLILoginURL()) + fmt.Fprintf(cmd.ErrOrStderr(), "Sign in and copy your token from:\n\n %s\n\nWaiting for it on your clipboard (Ctrl-C cancels)...\n", resolveCLILoginURL(resolveLoginBaseURL(cmd))) } deadline := time.Now().Add(clipboardWaitBudget) for { @@ -182,7 +182,7 @@ re-authenticates whichever profile is currently selected (see 'cio profile').`, // Print the URL rather than shelling out to a browser — works // under SSH, headless CI, and restrictive sandboxes. - loginURL := resolveCLILoginURL() + loginURL := resolveCLILoginURL(resolveLoginBaseURL(cmd)) fmt.Fprintf(cmd.ErrOrStderr(), "Open this URL in a browser to create a CLI token:\n\n %s\n\n", loginURL) fmt.Fprint(cmd.ErrOrStderr(), "After logging in, copy the token shown and paste it here.\n") @@ -620,14 +620,13 @@ func loadStoredServiceAccountToken() string { // web UI. The CLI's stored credentials are unchanged — this flow only // bootstraps a browser session, it does not refresh the saved token. func runLoginCLILink(cmd *cobra.Command, saToken string) error { - baseURL := resolveLoginAPIURL(cmd) + // Honor the host the stored credentials were issued against before falling + // back to the region default. Otherwise a token minted on a non-production + // host gets exchanged against us.fly.customer.io and is rejected with a 401 + // — the same resolution order used by every other command. + baseURL := resolveLoginBaseURL(cmd) if baseURL == "" { - // Use the same default as the rest of the CLI when --api-url isn't set. - region := "us" - if creds, err := client.ReadCredentials(); err == nil && creds.Region != "" { - region = creds.Region - } - baseURL = client.BaseURLForRegion(region) + baseURL = client.BaseURLForRegion("us") } timeout, _ := cmd.Flags().GetDuration("timeout") @@ -636,7 +635,7 @@ func runLoginCLILink(cmd *cobra.Command, saToken string) error { return handleAPIError(err) } - uiURL := resolveCLILoginURL() + "?token=" + url.QueryEscape(resp.HandoffToken) + uiURL := resolveCLILoginURL(baseURL) + "?token=" + url.QueryEscape(resp.HandoffToken) fmt.Fprintf(cmd.ErrOrStderr(), "You're already signed in. Open this URL in your browser to access Customer.io:\n\n %s\n\n", uiURL) fmt.Fprintf(cmd.ErrOrStderr(), "This link is valid for %d seconds.\n", resp.ExpiresIn) @@ -649,17 +648,60 @@ func runLoginCLILink(cmd *cobra.Command, saToken string) error { }) } -// resolveCLILoginURL returns the shared hosted CLI login URL. -// CIO_UI_URL can override the UI origin for non-production or test flows. -// The API URL is intentionally ignored here: it is a backend host and bears no -// relation to where the UI is served. -func resolveCLILoginURL() string { +// resolveCLILoginURL returns the hosted CLI login page URL for a given API base +// URL. CIO_UI_URL overrides the origin entirely (non-production or test flows). +// Otherwise the /cli page is served by the shared, region-less frontend that +// fronts the API host, so we derive the UI origin by dropping the region label: +// +// https://us.fly.customer.io -> https://fly.customer.io/cli +// https://eu.fly.customer.io -> https://fly.customer.io/cli +func resolveCLILoginURL(apiBaseURL string) string { if envURL := os.Getenv("CIO_UI_URL"); envURL != "" { return strings.TrimRight(envURL, "/") + "/cli" } + if origin := uiOriginFromAPIBase(apiBaseURL); origin != "" { + return origin + "/cli" + } return "https://fly.customer.io/cli" } +// uiOriginFromAPIBase derives the region-less UI origin from an API base URL by +// stripping a leading "us." or "eu." region label from the host. Returns "" for +// an unparseable or empty URL so the caller can fall back to the default. +func uiOriginFromAPIBase(apiBaseURL string) string { + u, err := url.Parse(strings.TrimRight(apiBaseURL, "/")) + if err != nil || u.Scheme == "" || u.Host == "" { + return "" + } + host := u.Host + if i := strings.IndexByte(host, '.'); i > 0 { + switch strings.ToLower(host[:i]) { + case "us", "eu": + host = host[i+1:] + } + } + return u.Scheme + "://" + host +} + +// resolveLoginBaseURL determines the API base URL the login flow should talk to, +// in the same priority order as client.ResolveBaseURL: --api-url > CIO_API_URL > +// the active profile's stored APIURL > the region default. Returns "" only when +// nothing is known (e.g. a first-time login with no stored credentials). +func resolveLoginBaseURL(cmd *cobra.Command) string { + if u := resolveLoginAPIURL(cmd); u != "" { + return u + } + if creds, err := client.ReadCredentials(); err == nil { + if creds.APIURL != "" { + return creds.APIURL + } + if creds.Region != "" { + return client.BaseURLForRegion(creds.Region) + } + } + return "" +} + // resolveLoginAPIURL picks --api-url > CIO_API_URL > the default token // exchange path inside DiscoverRegion. func resolveLoginAPIURL(cmd *cobra.Command) string { diff --git a/cmd/auth_test.go b/cmd/auth_test.go index 9ea63d6..a0ebdfd 100644 --- a/cmd/auth_test.go +++ b/cmd/auth_test.go @@ -346,7 +346,7 @@ func TestResolveCLILoginURL(t *testing.T) { cases := []struct { name string uiURLEnv string - region string + apiBase string want string }{ { @@ -354,25 +354,40 @@ func TestResolveCLILoginURL(t *testing.T) { want: "https://fly.customer.io/cli", }, { - name: "CIO_REGION=eu still uses the shared frontend host", - region: "eu", - want: "https://fly.customer.io/cli", + name: "US base strips the region label to the shared frontend", + apiBase: "https://us.fly.customer.io", + want: "https://fly.customer.io/cli", + }, + { + name: "EU base strips the region label to the shared frontend", + apiBase: "https://eu.fly.customer.io", + want: "https://fly.customer.io/cli", + }, + { + name: "non-production base strips the region label, keeping the host", + apiBase: "https://us.example.test", + want: "https://example.test/cli", + }, + { + name: "trailing slash on the base is tolerated", + apiBase: "https://us.example.test/", + want: "https://example.test/cli", }, { name: "CIO_UI_URL overrides everything", uiURLEnv: "http://fly.test:4200/", + apiBase: "https://us.example.test", want: "http://fly.test:4200/cli", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - t.Setenv("CIO_REGION", tc.region) t.Setenv("CIO_UI_URL", tc.uiURLEnv) - got := resolveCLILoginURL() + got := resolveCLILoginURL(tc.apiBase) if got != tc.want { - t.Errorf("resolveCLILoginURL() = %q, want %q", got, tc.want) + t.Errorf("resolveCLILoginURL(%q) = %q, want %q", tc.apiBase, got, tc.want) } }) } @@ -500,6 +515,114 @@ func TestAuthLogin_StoredTokenTriggersWebHandoff(t *testing.T) { } } +// TestAuthLogin_StoredTokenHandoffUsesStoredAPIURL is the regression test for +// the bug where `cio auth login` (no args) on a profile pointed at a +// non-production host minted the handoff link against us.fly.customer.io and +// got a 401 — even though `cio auth status` worked, since +// it honors the stored api_url. The handoff must hit the stored host too. +func TestAuthLogin_StoredTokenHandoffUsesStoredAPIURL(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + t.Setenv("CIO_TOKEN", "") + t.Setenv("CIO_API_URL", "") + t.Setenv("CIO_UI_URL", "https://fly.example.test") + + mintHits := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/login_cli/link" { + mintHits++ + _, _ = w.Write([]byte(`{"handoff_token":"handoff-jwt-xyz","expires_in":60}`)) + return + } + t.Errorf("unexpected request to %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + // Seed a profile whose stored APIURL is the test server (a stand-in for a + // non-production host). Crucially, no --api-url flag and no CIO_API_URL. + creds := &client.Credentials{ + ServiceAccountToken: "sa_live_existing", + AccountID: "42", + Region: "us", + APIURL: server.URL, + } + if err := client.WriteCredentials(creds); err != nil { + t.Fatalf("seed credentials: %v", err) + } + + stdout, stderr, err := executeCommand("auth", "login") + if err != nil { + t.Fatalf("unexpected error: %v\nstderr: %s", err, stderr) + } + if mintHits != 1 { + t.Fatalf("expected 1 mint request to the stored host, got %d", mintHits) + } + + var result map[string]any + if err := json.Unmarshal([]byte(stdout), &result); err != nil { + t.Fatalf("invalid JSON output: %v\nstdout: %s", err, stdout) + } + gotURL, _ := result["url"].(string) + want := "https://fly.example.test/cli?token=handoff-jwt-xyz" + if gotURL != want { + t.Errorf("expected URL %q, got %q", want, gotURL) + } +} + +// TestAuthLogin_HandoffURLDerivedFromBaseURL is the regression test for the +// second half of the bug: the printed browser URL was hardcoded to the +// production frontend (fly.customer.io) instead of being derived from the host +// the token actually belongs to. Without CIO_UI_URL set, the handoff URL must +// share the base URL's host (region label stripped), not point at production. +func TestAuthLogin_HandoffURLDerivedFromBaseURL(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + t.Setenv("CIO_TOKEN", "") + t.Setenv("CIO_API_URL", "") + t.Setenv("CIO_UI_URL", "") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/login_cli/link" { + _, _ = w.Write([]byte(`{"handoff_token":"handoff-jwt-derived","expires_in":60}`)) + return + } + t.Errorf("unexpected request to %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + creds := &client.Credentials{ + ServiceAccountToken: "sa_live_existing", + AccountID: "42", + Region: "us", + APIURL: server.URL, + } + if err := client.WriteCredentials(creds); err != nil { + t.Fatalf("seed credentials: %v", err) + } + + stdout, stderr, err := executeCommand("auth", "login") + if err != nil { + t.Fatalf("unexpected error: %v\nstderr: %s", err, stderr) + } + + var result map[string]any + if err := json.Unmarshal([]byte(stdout), &result); err != nil { + t.Fatalf("invalid JSON output: %v\nstdout: %s", err, stdout) + } + gotURL, _ := result["url"].(string) + // uiOriginFromAPIBase only strips a leading us./eu. label; the test server's + // 127.0.0.1 host is preserved, so the handoff stays on the mint host. + want := server.URL + "/cli?token=handoff-jwt-derived" + if gotURL != want { + t.Errorf("expected handoff URL derived from base %q, got %q", want, gotURL) + } + if strings.Contains(gotURL, "fly.customer.io") { + t.Errorf("handoff URL should not point at production, got %q", gotURL) + } +} + func TestAuthLogin_HelpMentionsBrowserFlow(t *testing.T) { // Inspect `Long` directly instead of calling `--help`: cobra's help path // mutates shared command state on the global rootCmd, which leaks into