Skip to content

Commit b26b685

Browse files
authored
feat(tokens): permanently delete agent tokens to free the name for reuse (#820) (#821)
Revoking an agent token is a soft delete: the record is kept so the token name stays reserved, which blocks creating a new token with the same name (issue #820). This adds a permanent delete that removes the token record and its name->hash mapping, freeing the name for reuse. - storage: DeleteAgentToken(name) removes both the agent_tokens record and the agent_token_names mapping in one tx. - REST: DELETE /api/v1/tokens/{name}/permanent (distinct from the existing revoke DELETE /api/v1/tokens/{name}). Admin-only, 204/404. - server edition: parallel DELETE /api/v1/user/tokens/{name}/permanent, scoped to the authenticated user. - CLI: `mcpproxy token delete <name>` (aliases: rm, remove). - Web UI: a Delete button on revoked/expired tokens (active tokens must be revoked first) with a confirm dialog. - docs + agent-tokens API contract updated. Tests: storage/API delete + name-reuse-after-delete, CLI subcommand wiring.
1 parent dd1b99c commit b26b685

13 files changed

Lines changed: 431 additions & 9 deletions

File tree

cmd/mcpproxy/token_cmd.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ Examples:
4949
tokenCmd.AddCommand(newTokenListCmd())
5050
tokenCmd.AddCommand(newTokenShowCmd())
5151
tokenCmd.AddCommand(newTokenRevokeCmd())
52+
tokenCmd.AddCommand(newTokenDeleteCmd())
5253
tokenCmd.AddCommand(newTokenRegenerateCmd())
5354

5455
return tokenCmd
@@ -395,6 +396,54 @@ func runTokenRevoke(_ *cobra.Command, args []string) error {
395396
return nil
396397
}
397398

399+
func newTokenDeleteCmd() *cobra.Command {
400+
return &cobra.Command{
401+
Use: "delete <name>",
402+
Aliases: []string{"rm", "remove"},
403+
Short: "Permanently delete an agent token",
404+
Long: `Permanently delete an agent token, removing it entirely and freeing its
405+
name for reuse. Unlike revoke (a soft delete that keeps the record so the name
406+
stays reserved), delete removes the token completely.
407+
408+
Examples:
409+
mcpproxy token delete deploy-bot`,
410+
Args: cobra.ExactArgs(1),
411+
RunE: runTokenDelete,
412+
}
413+
}
414+
415+
func runTokenDelete(_ *cobra.Command, args []string) error {
416+
client, _, err := newTokenCLIClient()
417+
if err != nil {
418+
return err
419+
}
420+
421+
name := args[0]
422+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
423+
defer cancel()
424+
425+
resp, err := client.DoRaw(ctx, http.MethodDelete, "/api/v1/tokens/"+name+"/permanent", nil)
426+
if err != nil {
427+
return fmt.Errorf("failed to delete token: %w", err)
428+
}
429+
defer resp.Body.Close()
430+
431+
respBody, err := io.ReadAll(resp.Body)
432+
if err != nil {
433+
return fmt.Errorf("failed to read response: %w", err)
434+
}
435+
436+
if resp.StatusCode == http.StatusNotFound {
437+
return fmt.Errorf("token %q not found", name)
438+
}
439+
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
440+
return parseAPIError(respBody, resp.StatusCode, "delete token")
441+
}
442+
443+
fmt.Printf("Token %q has been permanently deleted.\n", name)
444+
return nil
445+
}
446+
398447
func newTokenRegenerateCmd() *cobra.Command {
399448
return &cobra.Command{
400449
Use: "regenerate <name>",

cmd/mcpproxy/token_cmd_test.go

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,34 @@ func TestGetTokenCommand(t *testing.T) {
2323
assert.True(t, names["list"], "should have 'list' subcommand")
2424
assert.True(t, names["show"], "should have 'show' subcommand")
2525
assert.True(t, names["revoke"], "should have 'revoke' subcommand")
26+
assert.True(t, names["delete"], "should have 'delete' subcommand")
2627
assert.True(t, names["regenerate"], "should have 'regenerate' subcommand")
2728
}
2829

30+
func TestGetTokenCommand_IncludesDelete(t *testing.T) {
31+
cmd := GetTokenCommand()
32+
33+
// Find the delete subcommand
34+
var deleteCmd *cobra.Command
35+
for _, sub := range cmd.Commands() {
36+
if sub.Name() == "delete" {
37+
deleteCmd = sub
38+
break
39+
}
40+
}
41+
42+
assert.NotNil(t, deleteCmd, "delete subcommand must exist")
43+
assert.Equal(t, "delete <name>", deleteCmd.Use)
44+
assert.Contains(t, deleteCmd.Short, "delete")
45+
assert.NotEmpty(t, deleteCmd.Long)
46+
assert.Contains(t, deleteCmd.Aliases, "rm", "delete should alias 'rm'")
47+
48+
// Verify it requires exactly 1 argument
49+
assert.Error(t, deleteCmd.Args(deleteCmd, []string{}), "should reject zero args")
50+
assert.NoError(t, deleteCmd.Args(deleteCmd, []string{"my-token"}), "should accept one arg")
51+
assert.Error(t, deleteCmd.Args(deleteCmd, []string{"a", "b"}), "should reject two args")
52+
}
53+
2954
func TestGetTokenCommand_IncludesRegenerate(t *testing.T) {
3055
cmd := GetTokenCommand()
3156

@@ -76,7 +101,7 @@ func TestGetMapString(t *testing.T) {
76101

77102
assert.Equal(t, "deploy-bot", getMapString(m, "name"))
78103
assert.Equal(t, "", getMapString(m, "count")) // not a string
79-
assert.Equal(t, "", getMapString(m, "nonexistent")) // missing key
104+
assert.Equal(t, "", getMapString(m, "nonexistent")) // missing key
80105
}
81106

82107
func TestJoinInterfaceSlice(t *testing.T) {

docs/features/agent-tokens.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -231,12 +231,23 @@ mcpproxy token show deploy-bot
231231

232232
### Revoke a Token
233233

234-
Immediately invalidates the token:
234+
Immediately invalidates the token. Revoke is a **soft delete**: the record is kept
235+
(so the token name stays reserved) and any further use is rejected:
235236

236237
```bash
237238
mcpproxy token revoke deploy-bot
238239
```
239240

241+
### Delete a Token
242+
243+
Permanently removes the token, freeing its name for reuse. Unlike revoke, delete
244+
removes the record entirely — after deleting, you can create a new token with the
245+
same name:
246+
247+
```bash
248+
mcpproxy token delete deploy-bot # aliases: rm, remove
249+
```
250+
240251
### Regenerate a Token
241252

242253
Invalidates the old secret and generates a new one, keeping the same name and settings:
@@ -280,7 +291,8 @@ Agent tokens can also be managed via the REST API (requires admin API key):
280291
| `POST` | `/api/v1/tokens` | Create a new agent token |
281292
| `GET` | `/api/v1/tokens` | List all tokens |
282293
| `GET` | `/api/v1/tokens/{name}` | Get token details |
283-
| `DELETE` | `/api/v1/tokens/{name}` | Revoke a token |
294+
| `DELETE` | `/api/v1/tokens/{name}` | Revoke a token (soft delete; name stays reserved) |
295+
| `DELETE` | `/api/v1/tokens/{name}/permanent` | Permanently delete a token (frees the name for reuse) |
284296
| `POST` | `/api/v1/tokens/{name}/regenerate` | Regenerate token secret |
285297

286298
### Create Token via API

frontend/src/services/api.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1101,6 +1101,13 @@ class APIService {
11011101
})
11021102
}
11031103

1104+
// Permanently delete a token, freeing its name for reuse (unlike revoke, a soft delete).
1105+
async deleteAgentToken(name: string): Promise<APIResponse<void>> {
1106+
return this.request<void>(`/api/v1/tokens/${encodeURIComponent(name)}/permanent`, {
1107+
method: 'DELETE',
1108+
})
1109+
}
1110+
11041111
async regenerateAgentToken(name: string): Promise<APIResponse<{ name: string; token: string }>> {
11051112
return this.request<{ name: string; token: string }>(`/api/v1/tokens/${encodeURIComponent(name)}/regenerate`, {
11061113
method: 'POST',

frontend/src/views/AgentTokens.vue

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,17 @@
198198
</svg>
199199
Revoke
200200
</button>
201+
<button
202+
v-if="token.revoked || isExpired(token)"
203+
@click="handleDelete(token.name)"
204+
class="btn btn-xs btn-error"
205+
title="Permanently delete token and free its name for reuse"
206+
>
207+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
208+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
209+
</svg>
210+
Delete
211+
</button>
201212
</div>
202213
</td>
203214
</tr>
@@ -643,6 +654,38 @@ async function handleRevoke(name: string) {
643654
}
644655
}
645656
657+
// Permanently delete a (revoked or expired) token, freeing its name for reuse
658+
async function handleDelete(name: string) {
659+
if (!confirm(`Permanently delete token "${name}"? This removes it completely and frees the name for reuse. This action cannot be undone.`)) {
660+
return
661+
}
662+
663+
try {
664+
const response = await apiClient.deleteAgentToken(name)
665+
if (response.success || !response.error) {
666+
await loadTokens()
667+
668+
systemStore.addToast({
669+
type: 'success',
670+
title: 'Token Deleted',
671+
message: `Token "${name}" has been permanently deleted`,
672+
})
673+
} else {
674+
systemStore.addToast({
675+
type: 'error',
676+
title: 'Delete Failed',
677+
message: response.error || 'Failed to delete token',
678+
})
679+
}
680+
} catch (err: any) {
681+
systemStore.addToast({
682+
type: 'error',
683+
title: 'Delete Failed',
684+
message: err.message || 'Failed to delete token',
685+
})
686+
}
687+
}
688+
646689
// Clipboard
647690
async function copyToken() {
648691
if (!newTokenSecret.value) return

internal/httpapi/auth_middleware_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,11 @@ type testTokenStore struct {
2424
validateFunc func(rawToken string, hmacKey []byte) (*auth.AgentToken, error)
2525
}
2626

27-
func (s *testTokenStore) CreateAgentToken(_ auth.AgentToken, _ string, _ []byte) error { return nil }
28-
func (s *testTokenStore) ListAgentTokens() ([]auth.AgentToken, error) { return nil, nil }
29-
func (s *testTokenStore) GetAgentTokenByName(_ string) (*auth.AgentToken, error) { return nil, nil }
30-
func (s *testTokenStore) RevokeAgentToken(_ string) error { return nil }
27+
func (s *testTokenStore) CreateAgentToken(_ auth.AgentToken, _ string, _ []byte) error { return nil }
28+
func (s *testTokenStore) ListAgentTokens() ([]auth.AgentToken, error) { return nil, nil }
29+
func (s *testTokenStore) GetAgentTokenByName(_ string) (*auth.AgentToken, error) { return nil, nil }
30+
func (s *testTokenStore) RevokeAgentToken(_ string) error { return nil }
31+
func (s *testTokenStore) DeleteAgentToken(_ string) error { return nil }
3132
func (s *testTokenStore) RegenerateAgentToken(_ string, _ string, _ []byte) (*auth.AgentToken, error) {
3233
return nil, nil
3334
}
@@ -398,4 +399,3 @@ func TestAPIKeyAuth_NoTokenStore_RejectsAgentToken(t *testing.T) {
398399
assert.Equal(t, http.StatusUnauthorized, w.Code,
399400
"Agent token should be rejected when token store is not configured")
400401
}
401-

internal/httpapi/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -761,6 +761,7 @@ func (s *Server) setupRoutes() {
761761
r.Route("/{name}", func(r chi.Router) {
762762
r.Get("/", s.handleGetToken)
763763
r.Delete("/", s.handleRevokeToken)
764+
r.Delete("/permanent", s.handleDeleteToken)
764765
r.Post("/regenerate", s.handleRegenerateToken)
765766
})
766767
})

internal/httpapi/tokens.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ type TokenStore interface {
2222
ListAgentTokens() ([]auth.AgentToken, error)
2323
GetAgentTokenByName(name string) (*auth.AgentToken, error)
2424
RevokeAgentToken(name string) error
25+
DeleteAgentToken(name string) error
2526
RegenerateAgentToken(name string, newRawToken string, hmacKey []byte) (*auth.AgentToken, error)
2627
ValidateAgentToken(rawToken string, hmacKey []byte) (*auth.AgentToken, error)
2728
UpdateAgentTokenLastUsed(name string) error
@@ -292,6 +293,36 @@ func (s *Server) handleRevokeToken(w http.ResponseWriter, r *http.Request) {
292293
w.WriteHeader(http.StatusNoContent)
293294
}
294295

296+
// handleDeleteToken handles DELETE /api/v1/tokens/{name}/permanent
297+
// It permanently removes a token (unlike revoke, which is a soft delete),
298+
// freeing the name so it can be reused for a new token.
299+
func (s *Server) handleDeleteToken(w http.ResponseWriter, r *http.Request) {
300+
if !s.requireAdminAuth(w, r) {
301+
return
302+
}
303+
if !s.requireTokenStore(w, r) {
304+
return
305+
}
306+
307+
name := chi.URLParam(r, "name")
308+
if name == "" {
309+
s.writeError(w, r, http.StatusBadRequest, "Token name is required")
310+
return
311+
}
312+
313+
if err := s.tokenStore.DeleteAgentToken(name); err != nil {
314+
if strings.Contains(err.Error(), "not found") {
315+
s.writeError(w, r, http.StatusNotFound, fmt.Sprintf("Token %q not found", name))
316+
return
317+
}
318+
s.logger.Errorf("Failed to delete agent token: %v", err)
319+
s.writeError(w, r, http.StatusInternalServerError, "Failed to delete token")
320+
return
321+
}
322+
323+
w.WriteHeader(http.StatusNoContent)
324+
}
325+
295326
// handleRegenerateToken handles POST /api/v1/tokens/{name}/regenerate
296327
func (s *Server) handleRegenerateToken(w http.ResponseWriter, r *http.Request) {
297328
if !s.requireAdminAuth(w, r) {

internal/httpapi/tokens_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ type mockTokenStore struct {
2424
tokens map[string]auth.AgentToken
2525
createErr error
2626
revokeErr error
27+
deleteErr error
2728
regenToken *auth.AgentToken
2829
regenErr error
2930
}
@@ -75,6 +76,17 @@ func (m *mockTokenStore) RevokeAgentToken(name string) error {
7576
return nil
7677
}
7778

79+
func (m *mockTokenStore) DeleteAgentToken(name string) error {
80+
if m.deleteErr != nil {
81+
return m.deleteErr
82+
}
83+
if _, ok := m.tokens[name]; !ok {
84+
return fmt.Errorf("agent token %q not found", name)
85+
}
86+
delete(m.tokens, name)
87+
return nil
88+
}
89+
7890
func (m *mockTokenStore) ValidateAgentToken(rawToken string, _ []byte) (*auth.AgentToken, error) {
7991
// Return a valid agent token for any mcp_agt_ prefixed token
8092
if auth.ValidateTokenFormat(rawToken) {
@@ -628,6 +640,59 @@ func TestRevokeToken_NotFound(t *testing.T) {
628640
assert.Equal(t, http.StatusNotFound, w.Code)
629641
}
630642

643+
func TestDeleteToken(t *testing.T) {
644+
store := newMockTokenStore()
645+
srv := newTestTokenServer(t, store, nil)
646+
647+
// Create a token
648+
body := createTokenRequest{
649+
Name: "delete-me",
650+
Permissions: []string{"read"},
651+
}
652+
w := doRequest(t, srv, http.MethodPost, "/api/v1/tokens", body)
653+
require.Equal(t, http.StatusCreated, w.Code)
654+
655+
// Permanently delete it
656+
w = doRequest(t, srv, http.MethodDelete, "/api/v1/tokens/delete-me/permanent", nil)
657+
assert.Equal(t, http.StatusNoContent, w.Code)
658+
659+
// Verify it's gone
660+
stored, err := store.GetAgentTokenByName("delete-me")
661+
require.NoError(t, err)
662+
assert.Nil(t, stored)
663+
}
664+
665+
func TestDeleteToken_NotFound(t *testing.T) {
666+
store := newMockTokenStore()
667+
srv := newTestTokenServer(t, store, nil)
668+
669+
w := doRequest(t, srv, http.MethodDelete, "/api/v1/tokens/nonexistent/permanent", nil)
670+
assert.Equal(t, http.StatusNotFound, w.Code)
671+
}
672+
673+
// TestDeleteToken_FreesNameForReuse verifies the issue #820 flow end-to-end at
674+
// the API layer: revoke reserves the name, delete frees it for reuse.
675+
func TestDeleteToken_FreesNameForReuse(t *testing.T) {
676+
store := newMockTokenStore()
677+
srv := newTestTokenServer(t, store, nil)
678+
679+
body := createTokenRequest{Name: "reusable", Permissions: []string{"read"}}
680+
w := doRequest(t, srv, http.MethodPost, "/api/v1/tokens", body)
681+
require.Equal(t, http.StatusCreated, w.Code)
682+
683+
// Revoke keeps the name reserved -> re-create conflicts.
684+
w = doRequest(t, srv, http.MethodDelete, "/api/v1/tokens/reusable", nil)
685+
require.Equal(t, http.StatusNoContent, w.Code)
686+
w = doRequest(t, srv, http.MethodPost, "/api/v1/tokens", body)
687+
assert.Equal(t, http.StatusConflict, w.Code)
688+
689+
// Permanent delete frees the name -> re-create succeeds.
690+
w = doRequest(t, srv, http.MethodDelete, "/api/v1/tokens/reusable/permanent", nil)
691+
require.Equal(t, http.StatusNoContent, w.Code)
692+
w = doRequest(t, srv, http.MethodPost, "/api/v1/tokens", body)
693+
assert.Equal(t, http.StatusCreated, w.Code)
694+
}
695+
631696
func TestRegenerateToken(t *testing.T) {
632697
store := newMockTokenStore()
633698
srv := newTestTokenServer(t, store, nil)

0 commit comments

Comments
 (0)