diff --git a/cmd/doctor/doctor.go b/cmd/doctor/doctor.go index 409cbc7..b3fdfa8 100644 --- a/cmd/doctor/doctor.go +++ b/cmd/doctor/doctor.go @@ -139,11 +139,15 @@ func getHost(f *cmdutil.Factory) (string, error) { // doGet performs a GET request against url with the given context, returning // the response. The caller is responsible for closing resp.Body. -func doGet(ctx context.Context, client *http.Client, url string) (*http.Response, error) { +// +// host is used to look up per-host headers (hosts.yml + Cloudflare Access env) +// so doctor can reach environments behind CF Access. +func doGet(ctx context.Context, client *http.Client, host, url string) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } + khhttp.ApplyHostHeaders(req, host) return client.Do(req) } @@ -168,7 +172,7 @@ func checkAuth(ctx context.Context, f *cmdutil.Factory) CheckResult { } url := khhttp.BuildBaseURL(host) + "/api/auth/get-session" - resp, err := doGet(ctx, client, url) + resp, err := doGet(ctx, client, host, url) if err != nil { if isContextTimeout(err) { return CheckResult{Status: "warn", Message: "auth check timed out"} @@ -202,7 +206,7 @@ func checkAPI(ctx context.Context, f *cmdutil.Factory) CheckResult { url := khhttp.BuildBaseURL(host) + "/api/health" start := time.Now() - resp, err := doGet(ctx, client, url) + resp, err := doGet(ctx, client, host, url) latency := time.Since(start) if err != nil { @@ -234,7 +238,7 @@ func checkWallet(ctx context.Context, f *cmdutil.Factory) CheckResult { } url := khhttp.BuildBaseURL(host) + "/api/user/wallet/balances" - resp, err := doGet(ctx, client, url) + resp, err := doGet(ctx, client, host, url) if err != nil { if isContextTimeout(err) { return CheckResult{Status: "warn", Message: "wallet check timed out"} @@ -277,7 +281,7 @@ func checkSpendCap(ctx context.Context, f *cmdutil.Factory) CheckResult { } url := khhttp.BuildBaseURL(host) + "/api/billing/subscription" - resp, err := doGet(ctx, client, url) + resp, err := doGet(ctx, client, host, url) if err != nil { if isContextTimeout(err) { return CheckResult{Status: "warn", Message: "spend cap check timed out"} @@ -329,7 +333,7 @@ func checkChains(ctx context.Context, f *cmdutil.Factory) CheckResult { } url := khhttp.BuildBaseURL(host) + "/api/chains" - resp, err := doGet(ctx, client, url) + resp, err := doGet(ctx, client, host, url) if err != nil { if isContextTimeout(err) { return CheckResult{Status: "fail", Message: "could not reach chain service (timeout after 5s)"} diff --git a/cmd/kh/main.go b/cmd/kh/main.go index 2736aea..559fba6 100644 --- a/cmd/kh/main.go +++ b/cmd/kh/main.go @@ -88,7 +88,7 @@ func main() { return khhttp.NewClient(khhttp.ClientOptions{ Host: activeHost, Token: resolved.Token, - Headers: entry.Headers, + Headers: khhttp.MergeCloudflareAccessEnv(entry.Headers), OrgOverride: resolveOrgFlag(), IOStreams: ios, AppVersion: version.Version, diff --git a/docs/concepts.md b/docs/concepts.md index b5fabef..b54200a 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -26,6 +26,26 @@ Run `kh auth login` to authenticate via browser OAuth. For headless environments Run `kh auth status` to see which method is active and whether your token is valid. +## Gated Environments (Cloudflare Access) + +Hosts behind Cloudflare Access (PR previews, staging) require additional credentials on every request. The CLI sends them as headers from two sources: + +1. **Environment variables** (precedence: env > hosts.yml): + - `CF_ACCESS_CLIENT_ID` + `CF_ACCESS_CLIENT_SECRET` -- service-token pair, sent as `CF-Access-Client-Id` / `CF-Access-Client-Secret`. Both must be set; partial values are ignored. + - `CF_AUTHORIZATION` -- the `cf_authorization` JWT minted by `cloudflared access login`, sent as `Cookie: CF_Authorization=`. +2. **Per-host `headers:` map in `hosts.yml`** -- arbitrary headers attached to every request to that host: + + ```yaml + hosts: + app-pr-1234.keeperhub.com: + token: kh_prte_... + headers: + CF-Access-Client-Id: + CF-Access-Client-Secret: + ``` + +Use env vars in CI; use `hosts.yml` for stable per-environment config that follows your machine. + ## Output Formats By default, most commands render a human-readable table. Use these flags for machine-readable output: diff --git a/internal/auth/token.go b/internal/auth/token.go index e5fe684..bc97ed5 100644 --- a/internal/auth/token.go +++ b/internal/auth/token.go @@ -80,6 +80,7 @@ func fetchSessionInfo(host, token string) (TokenInfo, error) { return TokenInfo{}, fmt.Errorf("creating session request: %w", err) } req.Header.Set("Authorization", "Bearer "+token) + khhttp.ApplyHostHeaders(req, host) resp, err := client.Do(req) if err != nil { @@ -142,6 +143,7 @@ func fetchAPIKeyInfo(host, token string) (TokenInfo, error) { return TokenInfo{}, fmt.Errorf("creating validation request: %w", err) } req.Header.Set("Authorization", "Bearer "+token) + khhttp.ApplyHostHeaders(req, host) resp, err := client.Do(req) if err != nil { @@ -175,6 +177,7 @@ func fetchOrgDetails(client *http.Client, host, token, orgID string) (string, st return "", "" } req.Header.Set("Authorization", "Bearer "+token) + khhttp.ApplyHostHeaders(req, host) resp, err := client.Do(req) if err != nil || resp.StatusCode != http.StatusOK { diff --git a/internal/http/cfaccess.go b/internal/http/cfaccess.go new file mode 100644 index 0000000..a81bbdb --- /dev/null +++ b/internal/http/cfaccess.go @@ -0,0 +1,72 @@ +package khhttp + +import ( + "net/http" + "os" + + "github.com/keeperhub/cli/internal/config" +) + +// Cloudflare Access env vars. CF_ACCESS_CLIENT_ID + CF_ACCESS_CLIENT_SECRET +// is the service-token pair used by CI; CF_AUTHORIZATION carries the +// cf_authorization JWT minted by `cloudflared access login` for interactive +// devs. Both are sent when set; CF Access accepts either. +const ( + envCFAccessClientID = "CF_ACCESS_CLIENT_ID" + envCFAccessClientSecret = "CF_ACCESS_CLIENT_SECRET" + envCFAuthorization = "CF_AUTHORIZATION" +) + +// MergeCloudflareAccessEnv returns base with Cloudflare Access headers added +// from the environment. Env values take precedence over base entries with the +// same key (matching the KH_API_KEY > hosts.yml precedence used elsewhere). +// +// Service-token headers are only added when both ID and secret are set, to +// avoid sending half a credential pair. +func MergeCloudflareAccessEnv(base map[string]string) map[string]string { + id := os.Getenv(envCFAccessClientID) + secret := os.Getenv(envCFAccessClientSecret) + cookie := os.Getenv(envCFAuthorization) + + if id == "" && secret == "" && cookie == "" { + return base + } + + out := make(map[string]string, len(base)+3) + for k, v := range base { + out[k] = v + } + if id != "" && secret != "" { + out["CF-Access-Client-Id"] = id + out["CF-Access-Client-Secret"] = secret + } + if cookie != "" { + pair := "CF_Authorization=" + cookie + if existing, ok := out["Cookie"]; ok && existing != "" { + out["Cookie"] = existing + "; " + pair + } else { + out["Cookie"] = pair + } + } + return out +} + +// HeadersForHost returns the set of headers to apply to a request targeting +// host: per-host entries from hosts.yml with Cloudflare Access env vars merged +// on top. Used by code paths that build their own *http.Client and bypass the +// shared khhttp.Client (auth token validation, doctor checks). +func HeadersForHost(host string) map[string]string { + hosts, err := config.ReadHosts() + if err != nil { + return MergeCloudflareAccessEnv(nil) + } + entry, _ := hosts.HostEntry(host) + return MergeCloudflareAccessEnv(entry.Headers) +} + +// ApplyHostHeaders sets per-host headers (hosts.yml + CF env) on req. +func ApplyHostHeaders(req *http.Request, host string) { + for k, v := range HeadersForHost(host) { + req.Header.Set(k, v) + } +} diff --git a/internal/http/cfaccess_test.go b/internal/http/cfaccess_test.go new file mode 100644 index 0000000..6308609 --- /dev/null +++ b/internal/http/cfaccess_test.go @@ -0,0 +1,105 @@ +package khhttp_test + +import ( + "testing" + + khhttp "github.com/keeperhub/cli/internal/http" + "github.com/stretchr/testify/assert" +) + +func TestMergeCloudflareAccessEnv_NoEnv(t *testing.T) { + t.Setenv("CF_ACCESS_CLIENT_ID", "") + t.Setenv("CF_ACCESS_CLIENT_SECRET", "") + t.Setenv("CF_AUTHORIZATION", "") + + base := map[string]string{"X-Existing": "v"} + out := khhttp.MergeCloudflareAccessEnv(base) + + assert.Equal(t, base, out, "with no env vars, base should pass through unchanged") +} + +func TestMergeCloudflareAccessEnv_ServiceToken(t *testing.T) { + t.Setenv("CF_ACCESS_CLIENT_ID", "id-123") + t.Setenv("CF_ACCESS_CLIENT_SECRET", "secret-abc") + t.Setenv("CF_AUTHORIZATION", "") + + out := khhttp.MergeCloudflareAccessEnv(nil) + + assert.Equal(t, "id-123", out["CF-Access-Client-Id"]) + assert.Equal(t, "secret-abc", out["CF-Access-Client-Secret"]) + assert.NotContains(t, out, "Cookie") +} + +func TestMergeCloudflareAccessEnv_PartialServiceTokenIgnored(t *testing.T) { + // Only ID set, no secret -> nothing added (would 403 anyway and is misleading). + t.Setenv("CF_ACCESS_CLIENT_ID", "id-only") + t.Setenv("CF_ACCESS_CLIENT_SECRET", "") + t.Setenv("CF_AUTHORIZATION", "") + + out := khhttp.MergeCloudflareAccessEnv(nil) + + assert.NotContains(t, out, "CF-Access-Client-Id") + assert.NotContains(t, out, "CF-Access-Client-Secret") +} + +func TestMergeCloudflareAccessEnv_Cookie(t *testing.T) { + t.Setenv("CF_ACCESS_CLIENT_ID", "") + t.Setenv("CF_ACCESS_CLIENT_SECRET", "") + t.Setenv("CF_AUTHORIZATION", "jwt-token") + + out := khhttp.MergeCloudflareAccessEnv(nil) + + assert.Equal(t, "CF_Authorization=jwt-token", out["Cookie"]) +} + +func TestMergeCloudflareAccessEnv_EnvWinsOverBase(t *testing.T) { + t.Setenv("CF_ACCESS_CLIENT_ID", "env-id") + t.Setenv("CF_ACCESS_CLIENT_SECRET", "env-secret") + t.Setenv("CF_AUTHORIZATION", "") + + base := map[string]string{ + "CF-Access-Client-Id": "yaml-id", + "CF-Access-Client-Secret": "yaml-secret", + "X-Other": "preserved", + } + out := khhttp.MergeCloudflareAccessEnv(base) + + assert.Equal(t, "env-id", out["CF-Access-Client-Id"]) + assert.Equal(t, "env-secret", out["CF-Access-Client-Secret"]) + assert.Equal(t, "preserved", out["X-Other"]) +} + +func TestMergeCloudflareAccessEnv_CookieAppendsToExisting(t *testing.T) { + t.Setenv("CF_ACCESS_CLIENT_ID", "") + t.Setenv("CF_ACCESS_CLIENT_SECRET", "") + t.Setenv("CF_AUTHORIZATION", "jwt-token") + + base := map[string]string{"Cookie": "session=abc"} + out := khhttp.MergeCloudflareAccessEnv(base) + + assert.Equal(t, "session=abc; CF_Authorization=jwt-token", out["Cookie"], + "existing Cookie value should be preserved and CF_Authorization appended") +} + +func TestMergeCloudflareAccessEnv_ServiceTokenAndCookieCombined(t *testing.T) { + t.Setenv("CF_ACCESS_CLIENT_ID", "id-1") + t.Setenv("CF_ACCESS_CLIENT_SECRET", "secret-1") + t.Setenv("CF_AUTHORIZATION", "jwt-1") + + out := khhttp.MergeCloudflareAccessEnv(nil) + + assert.Equal(t, "id-1", out["CF-Access-Client-Id"]) + assert.Equal(t, "secret-1", out["CF-Access-Client-Secret"]) + assert.Equal(t, "CF_Authorization=jwt-1", out["Cookie"]) +} + +func TestMergeCloudflareAccessEnv_DoesNotMutateBase(t *testing.T) { + t.Setenv("CF_ACCESS_CLIENT_ID", "id") + t.Setenv("CF_ACCESS_CLIENT_SECRET", "secret") + t.Setenv("CF_AUTHORIZATION", "") + + base := map[string]string{"K": "v"} + _ = khhttp.MergeCloudflareAccessEnv(base) + + assert.Equal(t, map[string]string{"K": "v"}, base, "base map should not be mutated") +}