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
72 changes: 57 additions & 15 deletions cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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")

Expand All @@ -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)
Expand All @@ -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 {
Expand Down
137 changes: 130 additions & 7 deletions cmd/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -346,33 +346,48 @@ func TestResolveCLILoginURL(t *testing.T) {
cases := []struct {
name string
uiURLEnv string
region string
apiBase string
want string
}{
{
name: "default when nothing set is US fly-login",
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)
}
})
}
Expand Down Expand Up @@ -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
Expand Down