package main import ( "crypto/rand" "crypto/sha256" "database/sql" "encoding/hex" "errors" "flag" "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 } // runMintToken implements `kanban mint-token --user --name `. // Generates a fresh token, persists its sha256 in mcp_tokens, and prints the // plaintext ONCE to stdout. The caller must save it — the server keeps only // the hash. func runMintToken(args []string) error { fs := flag.NewFlagSet("kanban mint-token", flag.ContinueOnError) dbPath := fs.String("db", "operations.db", "SQLite database path") userID := fs.String("user", "", "owner user_id (must exist in users table)") name := fs.String("name", "", "label for this token (e.g. PC name)") if err := fs.Parse(args); err != nil { return err } if *userID == "" || *name == "" { return fmt.Errorf("--user and --name required") } db, err := openDB(*dbPath) if err != nil { return fmt.Errorf("open db: %w", err) } defer db.Close() var exists int if err := db.conn.QueryRow(`SELECT COUNT(*) FROM users WHERE id=?`, *userID).Scan(&exists); err != nil { return fmt.Errorf("user lookup: %w", err) } if exists == 0 { return fmt.Errorf("user %q not found", *userID) } plaintext, tok, err := db.MintMCPToken(*userID, *name) if err != nil { return fmt.Errorf("mint: %w", err) } fmt.Printf("token id: %s\n", tok.ID) fmt.Printf("name: %s\n", tok.Name) fmt.Printf("created_at: %s\n", tok.CreatedAt) fmt.Printf("\ntoken (save now, will not be shown again):\n%s\n", plaintext) return nil }