Files
kanban/backend/mcp_tokens.go
T
egutierrez c28ae7d3c0 chore: auto-commit (12 archivos)
- app.md
- backend/handlers.go
- backend/main.go
- frontend/src/App.tsx
- frontend/src/api.ts
- frontend/vite.config.ts
- backend/mcp_http.go
- backend/mcp_tokens.go
- backend/mcp_tokens_handlers.go
- backend/migrations/016_mcp_tokens.sql
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:38:17 +02:00

133 lines
3.5 KiB
Go

package main
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
)
// MCPToken is a per-user access token used by remote Claude clients to talk to
// the kanban MCP HTTP endpoint. The plaintext value is shown ONCE at creation
// time; we only persist the SHA-256 hash.
type MCPToken struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt string `json:"created_at"`
LastUsedAt *string `json:"last_used_at,omitempty"`
}
const mcpTokenPrefix = "kmcp_"
var errMCPTokenNotFound = errors.New("mcp token not found")
// MintMCPToken creates a new active token for userID and returns the plaintext
// value (caller must surface it to the user immediately; it cannot be
// recovered later) along with the row metadata.
func (db *DB) MintMCPToken(userID, name string) (string, *MCPToken, error) {
if userID == "" {
return "", nil, fmt.Errorf("user_id required")
}
plaintext, err := generateMCPTokenPlaintext()
if err != nil {
return "", nil, fmt.Errorf("generate token: %w", err)
}
tok := &MCPToken{
ID: newID(),
Name: name,
CreatedAt: nowRFC3339(),
}
_, err = db.conn.Exec(
`INSERT INTO mcp_tokens (id, user_id, token_hash, name, created_at) VALUES (?, ?, ?, ?, ?)`,
tok.ID, userID, hashMCPToken(plaintext), tok.Name, tok.CreatedAt,
)
if err != nil {
return "", nil, err
}
return plaintext, tok, nil
}
func (db *DB) ListMCPTokens(userID string) ([]MCPToken, error) {
rows, err := db.conn.Query(
`SELECT id, name, created_at, last_used_at FROM mcp_tokens
WHERE user_id=? AND revoked_at IS NULL
ORDER BY created_at DESC`, userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []MCPToken{}
for rows.Next() {
var t MCPToken
var lastUsed sql.NullString
if err := rows.Scan(&t.ID, &t.Name, &t.CreatedAt, &lastUsed); err != nil {
return nil, err
}
if lastUsed.Valid {
t.LastUsedAt = &lastUsed.String
}
out = append(out, t)
}
return out, rows.Err()
}
// RevokeMCPToken sets revoked_at on the token belonging to userID. Returns
// errMCPTokenNotFound if no active row matches.
func (db *DB) RevokeMCPToken(userID, tokenID string) error {
res, err := db.conn.Exec(
`UPDATE mcp_tokens SET revoked_at=? WHERE id=? AND user_id=? AND revoked_at IS NULL`,
nowRFC3339(), tokenID, userID,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return errMCPTokenNotFound
}
return nil
}
// LookupMCPToken hashes plaintext and returns the owning user_id if the token
// is active. Updates last_used_at as a side effect. Returns "" + nil when the
// token does not match an active row.
func (db *DB) LookupMCPToken(plaintext string) (string, error) {
if plaintext == "" {
return "", nil
}
hash := hashMCPToken(plaintext)
var userID, id string
err := db.conn.QueryRow(
`SELECT id, user_id FROM mcp_tokens WHERE token_hash=? AND revoked_at IS NULL`, hash,
).Scan(&id, &userID)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", err
}
if _, err := db.conn.Exec(`UPDATE mcp_tokens SET last_used_at=? WHERE id=?`, nowRFC3339(), id); err != nil {
return userID, fmt.Errorf("touch last_used_at: %w", err)
}
return userID, nil
}
func hashMCPToken(plaintext string) string {
sum := sha256.Sum256([]byte(plaintext))
return hex.EncodeToString(sum[:])
}
func generateMCPTokenPlaintext() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return mcpTokenPrefix + hex.EncodeToString(b), nil
}