Skip to content

Commit 47dbe97

Browse files
committed
fix(cli): validate without stale project headers; sync profile from API
Omit X-Team-ID/X-Project-ID on GET /cli-auth/validate via clientForCLIAuthValidate so a valid CLI key is not rejected when config.toml has a stale project_id. After successful validate on the existing-key login path, persist project_id, project_mode, and project_type from the response. Add Profile Apply* helpers for validate, poll, and CI responses; use them from login, interactive login, and MCP hookdeck_login. requireGatewayProject now applies the full validate response (including project_id) without clearing guest_url. Tests: auth validate headers, profile apply helpers, LoadConfigFromFile, gateway resolve-from-validate integration. Made-with: Cursor
1 parent 5f12dba commit 47dbe97

11 files changed

Lines changed: 306 additions & 30 deletions

pkg/cmd/gateway.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,14 +51,14 @@ func requireGatewayProject(cfg *config.Config) error {
5151
projectType = config.ModeToProjectType(cfg.Profile.ProjectMode)
5252
}
5353
if projectType == "" {
54-
// Resolve from API
54+
// Resolve team/project/mode/type from API (authoritative for the key). Do not clear
55+
// guest_url here — gateway PreRun may run for users who still have a guest upgrade link.
5556
response, err := cfg.GetAPIClient().ValidateAPIKey()
5657
if err != nil {
5758
return err
5859
}
59-
projectType = config.ModeToProjectType(response.ProjectMode)
60-
cfg.Profile.ProjectType = projectType
61-
cfg.Profile.ProjectMode = response.ProjectMode
60+
cfg.Profile.ApplyValidateAPIKeyResponse(response, false)
61+
projectType = cfg.Profile.ProjectType
6262
_ = cfg.Profile.SaveProfile()
6363
}
6464
if !config.IsGatewayProject(projectType) {

pkg/cmd/gateway_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
package cmd
22

33
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"os"
8+
"path/filepath"
49
"strings"
510
"testing"
611

712
"github.com/hookdeck/hookdeck-cli/pkg/config"
13+
"github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
814
"github.com/stretchr/testify/assert"
915
"github.com/stretchr/testify/require"
1016
)
@@ -87,6 +93,46 @@ func TestRequireGatewayProject(t *testing.T) {
8793
})
8894
}
8995

96+
func TestRequireGatewayProject_resolveFromValidate(t *testing.T) {
97+
config.ResetAPIClientForTesting()
98+
t.Cleanup(config.ResetAPIClientForTesting)
99+
100+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
101+
if r.URL.Path != hookdeck.APIPathPrefix+"/cli-auth/validate" {
102+
http.NotFound(w, r)
103+
return
104+
}
105+
w.Header().Set("Content-Type", "application/json")
106+
_ = json.NewEncoder(w).Encode(hookdeck.ValidateAPIKeyResponse{
107+
ProjectID: "team_from_validate",
108+
ProjectMode: "inbound",
109+
})
110+
}))
111+
t.Cleanup(server.Close)
112+
113+
dir := t.TempDir()
114+
path := filepath.Join(dir, "config.toml")
115+
toml := `profile = "default"
116+
117+
[default]
118+
api_key = "sk_test_123456789012"
119+
project_id = "stale_team_should_be_replaced"
120+
guest_url = "https://guest.example/keep-me"
121+
`
122+
require.NoError(t, os.WriteFile(path, []byte(toml), 0600))
123+
124+
cfg, err := config.LoadConfigFromFile(path)
125+
require.NoError(t, err)
126+
cfg.APIBaseURL = server.URL
127+
128+
err = requireGatewayProject(cfg)
129+
require.NoError(t, err)
130+
require.Equal(t, "team_from_validate", cfg.Profile.ProjectId)
131+
require.Equal(t, config.ProjectTypeGateway, cfg.Profile.ProjectType)
132+
require.Equal(t, "inbound", cfg.Profile.ProjectMode)
133+
require.Equal(t, "https://guest.example/keep-me", cfg.Profile.GuestURL, "gateway validate path must not clear guest_url")
134+
}
135+
90136
func TestGatewayPersistentPreRunE_MCP(t *testing.T) {
91137
old := Config
92138
t.Cleanup(func() { Config = old })

pkg/config/load_config_file.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package config
2+
3+
import (
4+
"github.com/spf13/viper"
5+
)
6+
7+
// LoadConfigFromFile loads an existing Hookdeck CLI TOML config into Config with viper and
8+
// filesystem helpers wired so SaveProfile / writeConfig work. Intended for integration tests;
9+
// production startup should use InitConfig.
10+
func LoadConfigFromFile(configPath string) (*Config, error) {
11+
v := viper.New()
12+
v.SetConfigFile(configPath)
13+
v.SetConfigType("toml")
14+
if err := v.ReadInConfig(); err != nil {
15+
return nil, err
16+
}
17+
c := &Config{viper: v, fs: newConfigFS()}
18+
c.Profile.Config = c
19+
c.constructConfig()
20+
return c, nil
21+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package config
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestLoadConfigFromFile(t *testing.T) {
12+
dir := t.TempDir()
13+
path := filepath.Join(dir, "config.toml")
14+
content := `profile = "default"
15+
16+
[default]
17+
api_key = "sk_test_123456789012"
18+
project_id = "proj_a"
19+
project_mode = "inbound"
20+
`
21+
require.NoError(t, os.WriteFile(path, []byte(content), 0600))
22+
23+
c, err := LoadConfigFromFile(path)
24+
require.NoError(t, err)
25+
require.NotNil(t, c.viper)
26+
require.Equal(t, "default", c.Profile.Name)
27+
require.Equal(t, "sk_test_123456789012", c.Profile.APIKey)
28+
require.Equal(t, "proj_a", c.Profile.ProjectId)
29+
require.Equal(t, "inbound", c.Profile.ProjectMode)
30+
require.Equal(t, ProjectTypeGateway, c.Profile.ProjectType)
31+
}

pkg/config/profile_credentials.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package config
2+
3+
import "github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
4+
5+
// ApplyValidateAPIKeyResponse updates project fields from GET /cli-auth/validate.
6+
// When clearGuestURL is true, GuestURL is cleared (e.g. hookdeck login re-verify).
7+
// When false, GuestURL is left unchanged (e.g. gateway PreRun resolving type only).
8+
func (p *Profile) ApplyValidateAPIKeyResponse(resp *hookdeck.ValidateAPIKeyResponse, clearGuestURL bool) {
9+
if resp == nil {
10+
return
11+
}
12+
p.ProjectId = resp.ProjectID
13+
p.ProjectMode = resp.ProjectMode
14+
p.ProjectType = ModeToProjectType(resp.ProjectMode)
15+
if clearGuestURL {
16+
p.GuestURL = ""
17+
}
18+
}
19+
20+
// ApplyPollAPIKeyResponse applies credentials from a completed CLI auth poll (browser or interactive login).
21+
// guestURL is the guest upgrade URL when applicable; use "" for a normal account login.
22+
func (p *Profile) ApplyPollAPIKeyResponse(resp *hookdeck.PollAPIKeyResponse, guestURL string) {
23+
if resp == nil {
24+
return
25+
}
26+
p.APIKey = resp.APIKey
27+
p.ProjectId = resp.ProjectID
28+
p.ProjectMode = resp.ProjectMode
29+
p.ProjectType = ModeToProjectType(resp.ProjectMode)
30+
p.GuestURL = guestURL
31+
}
32+
33+
// ApplyCIClient applies credentials from hookdeck login --ci.
34+
func (p *Profile) ApplyCIClient(ci hookdeck.CIClient) {
35+
p.APIKey = ci.APIKey
36+
p.ProjectId = ci.ProjectID
37+
p.ProjectMode = ci.ProjectMode
38+
p.ProjectType = ModeToProjectType(ci.ProjectMode)
39+
p.GuestURL = ""
40+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package config
2+
3+
import (
4+
"testing"
5+
6+
"github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestProfile_ApplyValidateAPIKeyResponse(t *testing.T) {
11+
t.Run("nil response is no-op", func(t *testing.T) {
12+
p := &Profile{ProjectId: "keep", GuestURL: "https://guest"}
13+
p.ApplyValidateAPIKeyResponse(nil, true)
14+
require.Equal(t, "keep", p.ProjectId)
15+
require.Equal(t, "https://guest", p.GuestURL)
16+
})
17+
18+
t.Run("sets project fields and clears guest when requested", func(t *testing.T) {
19+
p := &Profile{GuestURL: "https://guest"}
20+
p.ApplyValidateAPIKeyResponse(&hookdeck.ValidateAPIKeyResponse{
21+
ProjectID: "team_1",
22+
ProjectMode: "inbound",
23+
}, true)
24+
require.Equal(t, "team_1", p.ProjectId)
25+
require.Equal(t, "inbound", p.ProjectMode)
26+
require.Equal(t, ProjectTypeGateway, p.ProjectType)
27+
require.Empty(t, p.GuestURL)
28+
})
29+
30+
t.Run("preserves guest URL when clearGuestURL is false", func(t *testing.T) {
31+
p := &Profile{GuestURL: "https://guest.example/x"}
32+
p.ApplyValidateAPIKeyResponse(&hookdeck.ValidateAPIKeyResponse{
33+
ProjectID: "team_2",
34+
ProjectMode: "console",
35+
}, false)
36+
require.Equal(t, "team_2", p.ProjectId)
37+
require.Equal(t, ProjectTypeConsole, p.ProjectType)
38+
require.Equal(t, "https://guest.example/x", p.GuestURL)
39+
})
40+
}
41+
42+
func TestProfile_ApplyPollAPIKeyResponse(t *testing.T) {
43+
t.Run("nil response is no-op", func(t *testing.T) {
44+
p := &Profile{APIKey: "k", ProjectId: "p"}
45+
p.ApplyPollAPIKeyResponse(nil, "")
46+
require.Equal(t, "k", p.APIKey)
47+
require.Equal(t, "p", p.ProjectId)
48+
})
49+
50+
t.Run("sets credentials and guest URL", func(t *testing.T) {
51+
p := &Profile{}
52+
p.ApplyPollAPIKeyResponse(&hookdeck.PollAPIKeyResponse{
53+
APIKey: "key_from_poll",
54+
ProjectID: "team_p",
55+
ProjectMode: "inbound",
56+
}, "https://guest")
57+
require.Equal(t, "key_from_poll", p.APIKey)
58+
require.Equal(t, "team_p", p.ProjectId)
59+
require.Equal(t, ProjectTypeGateway, p.ProjectType)
60+
require.Equal(t, "https://guest", p.GuestURL)
61+
})
62+
63+
t.Run("clears-style guest with empty string", func(t *testing.T) {
64+
p := &Profile{GuestURL: "old"}
65+
p.ApplyPollAPIKeyResponse(&hookdeck.PollAPIKeyResponse{
66+
APIKey: "k123456789012",
67+
ProjectID: "t",
68+
ProjectMode: "inbound",
69+
}, "")
70+
require.Empty(t, p.GuestURL)
71+
})
72+
}
73+
74+
func TestProfile_ApplyCIClient(t *testing.T) {
75+
p := &Profile{}
76+
p.ApplyCIClient(hookdeck.CIClient{
77+
APIKey: "ci_key_123456",
78+
ProjectID: "team_ci",
79+
ProjectMode: "inbound",
80+
})
81+
require.Equal(t, "ci_key_123456", p.APIKey)
82+
require.Equal(t, "team_ci", p.ProjectId)
83+
require.Equal(t, ProjectTypeGateway, p.ProjectType)
84+
require.Empty(t, p.GuestURL)
85+
}

pkg/gateway/mcp/tool_login.go

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import (
1313

1414
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
1515

16-
"github.com/hookdeck/hookdeck-cli/pkg/config"
1716
"github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
1817
"github.com/hookdeck/hookdeck-cli/pkg/project"
1918
"github.com/hookdeck/hookdeck-cli/pkg/validators"
@@ -161,11 +160,7 @@ func handleLogin(srv *Server) mcpsdk.ToolHandler {
161160
}
162161

163162
// Persist credentials so future MCP sessions start authenticated.
164-
cfg.Profile.APIKey = response.APIKey
165-
cfg.Profile.ProjectId = response.ProjectID
166-
cfg.Profile.ProjectMode = response.ProjectMode
167-
cfg.Profile.ProjectType = config.ModeToProjectType(response.ProjectMode)
168-
cfg.Profile.GuestURL = ""
163+
cfg.Profile.ApplyPollAPIKeyResponse(response, "")
169164

170165
cfg.SaveActiveProfileAfterLogin()
171166

pkg/hookdeck/auth.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,28 @@ func (c *Client) PollForAPIKeyWithKey(apiKey string, interval time.Duration, max
133133
return pollForAPIKey(pollURL, interval, maxAttempts)
134134
}
135135

136+
// clientForCLIAuthValidate returns a shallow copy of the client that omits
137+
// X-Team-ID / X-Project-ID on requests. Stale project_id in config must not
138+
// be sent to /cli-auth/validate — the server may prefer headers over the key's
139+
// bound team and reject a valid key with 401.
140+
func (c *Client) clientForCLIAuthValidate() *Client {
141+
return &Client{
142+
BaseURL: c.BaseURL,
143+
APIKey: c.APIKey,
144+
ProjectID: "",
145+
ProjectOrg: c.ProjectOrg,
146+
ProjectName: c.ProjectName,
147+
Verbose: c.Verbose,
148+
SuppressRateLimitErrors: c.SuppressRateLimitErrors,
149+
Telemetry: c.Telemetry,
150+
TelemetryDisabled: c.TelemetryDisabled,
151+
httpClient: c.httpClient,
152+
}
153+
}
154+
136155
// ValidateAPIKey validates an API key and returns user/project information
137156
func (c *Client) ValidateAPIKey() (*ValidateAPIKeyResponse, error) {
138-
res, err := c.Get(context.Background(), APIPathPrefix+"/cli-auth/validate", "", nil)
157+
res, err := c.clientForCLIAuthValidate().Get(context.Background(), APIPathPrefix+"/cli-auth/validate", "", nil)
139158
if err != nil {
140159
return nil, err
141160
}

pkg/hookdeck/auth_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package hookdeck
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"net/url"
8+
"testing"
9+
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestValidateAPIKey_omitsTeamAndProjectHeadersWhenConfigHasProjectID(t *testing.T) {
14+
var sawTeamHeader bool
15+
var sawProjectHeader bool
16+
17+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
18+
sawTeamHeader = r.Header.Get("X-Team-ID") != ""
19+
sawProjectHeader = r.Header.Get("X-Project-ID") != ""
20+
if r.URL.Path != APIPathPrefix+"/cli-auth/validate" {
21+
http.NotFound(w, r)
22+
return
23+
}
24+
w.Header().Set("Content-Type", "application/json")
25+
_ = json.NewEncoder(w).Encode(ValidateAPIKeyResponse{
26+
UserID: "u1",
27+
UserName: "n",
28+
UserEmail: "e@e",
29+
OrganizationName: "o",
30+
OrganizationID: "o1",
31+
ProjectID: "t1",
32+
ProjectName: "p",
33+
ProjectMode: "gateway",
34+
})
35+
}))
36+
t.Cleanup(server.Close)
37+
38+
baseURL, err := url.Parse(server.URL)
39+
require.NoError(t, err)
40+
41+
client := &Client{
42+
BaseURL: baseURL,
43+
APIKey: "test_key",
44+
ProjectID: "stale_team_should_not_be_sent",
45+
}
46+
47+
resp, err := client.ValidateAPIKey()
48+
require.NoError(t, err)
49+
require.False(t, sawTeamHeader, "validate must not send X-Team-ID")
50+
require.False(t, sawProjectHeader, "validate must not send X-Project-ID")
51+
require.Equal(t, "t1", resp.ProjectID)
52+
}

0 commit comments

Comments
 (0)