Skip to content
Open
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hookdeck-cli",
"version": "2.1.1",
"version": "2.1.2-beta.1",
"description": "Hookdeck CLI",
"repository": {
"type": "git",
Expand Down
11 changes: 10 additions & 1 deletion pkg/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,16 @@ func Execute() {
}

default:
if gatewayMCP {
if hookdeck.IsUnauthorizedError(err) {
msg := "Authentication failed: your API key is invalid or expired.\n\n" +
"Sign in again: run `hookdeck login` (browser sign-in), or `hookdeck login -i` / `hookdeck --api-key <key> login`.\n\n" +
"MCP: use hookdeck_login with reauth: true."
if gatewayMCP {
fmt.Fprintln(os.Stderr, msg)
} else {
fmt.Println(msg)
}
} else if gatewayMCP {
fmt.Fprintln(os.Stderr, err)
} else {
fmt.Println(err)
Expand Down
29 changes: 27 additions & 2 deletions pkg/config/apiclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,36 @@ import (
var apiClient *hookdeck.Client
var apiClientOnce sync.Once

func resetAPIClient() {
apiClient = nil
apiClientOnce = sync.Once{}
}

// ResetAPIClientForTesting resets the global API client singleton so that
// tests can start with a fresh instance. Must only be called from tests.
func ResetAPIClientForTesting() {
apiClient = nil
apiClientOnce = sync.Once{}
resetAPIClient()
}

// RefreshCachedAPIClient copies the current config (API base, profile key and
// project id, log/telemetry flags) onto the cached *hookdeck.Client if one
// already exists. Use after login or other in-process profile updates so the
// singleton matches Profile without discarding the underlying http.Client.
// If GetAPIClient has never been called, this is a no-op (the next GetAPIClient
// will construct from Config).
func (c *Config) RefreshCachedAPIClient() {
if apiClient == nil {
return
}
baseURL, err := url.Parse(c.APIBaseURL)
if err != nil {
panic("Invalid API base URL: " + err.Error())
}
apiClient.BaseURL = baseURL
apiClient.APIKey = c.Profile.APIKey
apiClient.ProjectID = c.Profile.ProjectId
apiClient.Verbose = c.LogLevel == "debug"
apiClient.TelemetryDisabled = c.TelemetryDisabled
}

// GetAPIClient returns the internal API client instance
Expand Down
4 changes: 3 additions & 1 deletion pkg/gateway/mcp/tool_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,9 @@ func handleLogin(srv *Server) mcpsdk.ToolHandler {

cfg.SaveActiveProfileAfterLogin()

// Update the shared client so all resource tools start working.
// Update the server-held client (in production this is the same pointer as
// config.GetAPIClient(); tests inject a separate *hookdeck.Client, so we must
// mutate this handle — RefreshCachedAPIClient only touches the global singleton).
client.APIKey = response.APIKey
client.ProjectID = response.ProjectID
org, proj, err := project.ParseProjectName(response.ProjectName)
Expand Down
25 changes: 25 additions & 0 deletions pkg/hookdeck/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"net/http"
"net/url"
"os"
"strings"
"time"

"github.com/hookdeck/hookdeck-cli/pkg/useragent"
Expand Down Expand Up @@ -126,6 +127,22 @@ func IsNotFoundError(err error) bool {
return errors.As(err, &apiErr) && (apiErr.StatusCode == http.StatusNotFound || apiErr.StatusCode == http.StatusGone)
}

// IsUnauthorizedError reports whether err is an HTTP 401 from the Hookdeck API
// (invalid or rejected credentials). Non-JSON 401 bodies still become *APIError
// with StatusCode 401; a plain error string containing "status code: 401" is
// treated as unauthorized for wrapped failures.
func IsUnauthorizedError(err error) bool {
if err == nil {
return false
}
var apiErr *APIError
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusUnauthorized {
return true
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "status code: 401")
}

// PerformRequest sends a request to Hookdeck and returns the response.
func (c *Client) PerformRequest(ctx context.Context, req *http.Request) (*http.Response, error) {
if req.Header == nil {
Expand Down Expand Up @@ -208,6 +225,14 @@ func (c *Client) PerformRequest(ctx context.Context, req *http.Request) (*http.R
"url": req.URL.String(),
"status": resp.StatusCode,
}).Debug("Rate limited")
} else if resp.StatusCode == http.StatusUnauthorized {
// Invalid or expired keys are common; avoid ERROR-level noise (e.g. whoami, agents).
log.WithFields(log.Fields{
"prefix": "client.Client.PerformRequest",
"method": req.Method,
"url": req.URL.String(),
"status": resp.StatusCode,
}).Debug("Unauthorized response")
} else {
log.WithFields(log.Fields{
"prefix": "client.Client.PerformRequest 2",
Expand Down
14 changes: 14 additions & 0 deletions pkg/hookdeck/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package hookdeck

import (
"context"
"fmt"
"io"
"io/ioutil"
"net/http"
Expand All @@ -18,6 +19,19 @@ func TestIsNotFoundError_410Gone(t *testing.T) {
require.False(t, IsNotFoundError(&APIError{StatusCode: http.StatusInternalServerError, Message: "err"}))
}

func TestIsUnauthorizedError(t *testing.T) {
require.True(t, IsUnauthorizedError(&APIError{StatusCode: http.StatusUnauthorized, Message: "Unauthorized"}))
require.True(t, IsUnauthorizedError(&APIError{
StatusCode: http.StatusUnauthorized,
Message: "unexpected http status code: 401, raw response body: Unauthorized",
}))
require.True(t, IsUnauthorizedError(fmt.Errorf("wrapped: %w", &APIError{StatusCode: http.StatusUnauthorized})))
require.True(t, IsUnauthorizedError(fmt.Errorf("unexpected http status code: 401, raw response body: Unauthorized")))
require.False(t, IsUnauthorizedError(&APIError{StatusCode: http.StatusForbidden, Message: "nope"}))
require.False(t, IsUnauthorizedError(nil))
require.False(t, IsUnauthorizedError(fmt.Errorf("network down")))
}

func TestPerformRequest_ParamsEncoding_Delete(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/delete", r.URL.Path)
Expand Down
39 changes: 24 additions & 15 deletions pkg/login/client_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,22 +33,29 @@ func Login(config *configpkg.Config, input io.Reader) error {
s = ansi.StartNewSpinner("Verifying credentials...", os.Stdout)
response, err := config.GetAPIClient().ValidateAPIKey()
if err != nil {
return err
ansi.StopSpinner(s, "", os.Stdout)
if !hookdeck.IsUnauthorizedError(err) {
return err
}
// Rejected key: continue into browser login below (must clear key first
// or we would re-enter this branch only).
fmt.Fprintln(os.Stdout, "Your saved API key is no longer valid. Starting browser sign-in...")
config.Profile.APIKey = ""
} else {
message := SuccessMessage(response.UserName, response.UserEmail, response.OrganizationName, response.ProjectName, response.ProjectMode == "console")
ansi.StopSpinner(s, message, os.Stdout)

config.Profile.ApplyValidateAPIKeyResponse(response, true)

if err = config.Profile.SaveProfile(); err != nil {
return err
}
if err = config.Profile.UseProfile(); err != nil {
return err
}

return nil
}

message := SuccessMessage(response.UserName, response.UserEmail, response.OrganizationName, response.ProjectName, response.ProjectMode == "console")
ansi.StopSpinner(s, message, os.Stdout)

config.Profile.ApplyValidateAPIKeyResponse(response, true)

if err = config.Profile.SaveProfile(); err != nil {
return err
}
if err = config.Profile.UseProfile(); err != nil {
return err
}

return nil
}

parsedBaseURL, err := url.Parse(config.APIBaseURL)
Expand Down Expand Up @@ -103,6 +110,8 @@ func Login(config *configpkg.Config, input io.Reader) error {
return err
}

config.RefreshCachedAPIClient()

message := SuccessMessage(response.UserName, response.UserEmail, response.OrganizationName, response.ProjectName, response.ProjectMode == "console")
ansi.StopSpinner(s, message, os.Stdout)

Expand Down
121 changes: 121 additions & 0 deletions pkg/login/client_login_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package login

import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"

configpkg "github.com/hookdeck/hookdeck-cli/pkg/config"
"github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
"github.com/stretchr/testify/require"
)

// TestLogin_validateNonUnauthorizedStillFails verifies that credential
// verification errors other than 401 are returned immediately (no browser flow).
func TestLogin_validateNonUnauthorizedStillFails(t *testing.T) {
configpkg.ResetAPIClientForTesting()
t.Cleanup(configpkg.ResetAPIClientForTesting)

ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/cli-auth/validate") {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"message":"server boom"}`))
return
}
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}))
t.Cleanup(ts.Close)

cfg := &configpkg.Config{
APIBaseURL: ts.URL,
DeviceName: "test-device",
LogLevel: "error",
TelemetryDisabled: true,
}
cfg.Profile = configpkg.Profile{
Name: "default",
APIKey: "hk_test_123456789012",
Config: cfg,
}

err := Login(cfg, strings.NewReader("\n"))
require.Error(t, err)
}

// TestLogin_unauthorizedValidateStartsBrowserFlow checks that a 401 from
// validate is followed by POST /cli-auth (browser login), then a successful poll.
func TestLogin_unauthorizedValidateStartsBrowserFlow(t *testing.T) {
configpkg.ResetAPIClientForTesting()
t.Cleanup(configpkg.ResetAPIClientForTesting)

oldCan := canOpenBrowser
oldOpen := openBrowser
canOpenBrowser = func() bool { return false }
openBrowser = func(string) error { return nil }
t.Cleanup(func() {
canOpenBrowser = oldCan
openBrowser = oldOpen
})

pollHits := 0
var serverURL string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/cli-auth/validate"):
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte("Unauthorized"))
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/cli-auth"):
pollURL := serverURL + hookdeck.APIPathPrefix + "/cli-auth/poll?key=pollkey"
body, err := json.Marshal(map[string]string{
"browser_url": "https://example.test/auth",
"poll_url": pollURL,
})
require.NoError(t, err)
_, _ = w.Write(body)
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/cli-auth/poll"):
pollHits++
resp := map[string]interface{}{
"claimed": true,
"key": "hk_test_newkey_abcdefghij",
"team_id": "tm_1",
"team_mode": "gateway",
"team_name": "Proj",
"user_name": "U",
"user_email": "u@example.com",
"organization_name": "Org",
"organization_id": "org_1",
"client_id": "cl_1",
}
enc, err := json.Marshal(resp)
require.NoError(t, err)
_, _ = w.Write(enc)
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
}))
serverURL = ts.URL
t.Cleanup(ts.Close)

configPath := filepath.Join(t.TempDir(), "config.toml")
require.NoError(t, os.WriteFile(configPath, []byte(`profile = "default"

[default]
api_key = "hk_test_oldkey_abcdefghij"
`), 0o600))

cfg, err := configpkg.LoadConfigFromFile(configPath)
require.NoError(t, err)
cfg.APIBaseURL = ts.URL
cfg.DeviceName = "test-device"
cfg.LogLevel = "error"
cfg.TelemetryDisabled = true

err = Login(cfg, strings.NewReader("\n"))
require.NoError(t, err)
require.Equal(t, 1, pollHits, "poll should run once with immediate claimed=true")
require.Equal(t, "hk_test_newkey_abcdefghij", cfg.Profile.APIKey)
}
2 changes: 2 additions & 0 deletions test/acceptance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ These tests run automatically in CI using API keys from `hookdeck ci`. They don'

**Files:** Test files with **feature build tags** (e.g. `//go:build connection`, `//go:build request`). Each automated test file has exactly one feature tag so tests can be split into parallel slices (see [Parallelisation](#parallelisation)).

**Login recovery (mock API, `basic` tag):** `login_auth_acceptance_test.go` runs the real CLI with `--api-base` pointing at a local server that returns **401** on `GET .../cli-auth/validate`, then completes a fake device-auth poll — this asserts `hookdeck login` continues into browser/device flow after a stale key (no human, no real Hookdeck key). The same file includes **`TestCIFailsFastWithInvalidAPIKeyAcceptance`**, which runs `hookdeck ci --api-key` with a bogus key against the real API and expects a quick failure with the friendly **Authentication failed** message, and asserts output does **not** contain browser/device-login phrases (`Press Enter to open the browser`, `To authenticate with Hookdeck`, etc.) so CI never enters the interactive `hookdeck login` flow.

### 2. Manual Tests (Require Human Interaction)
These tests require browser-based authentication via `hookdeck login` and must be run manually by developers.

Expand Down
Loading
Loading